import type { RawAttribute, TextPart, Token } from "./types.ts"; const KEYWORDS = new Set([ "if", "unless", "else", "each", "while", "case", "when", "default", "mixin", "include", "extends", "block", "append", "prepend", "slot", "yield", "props", "component", "island", "render", "portal", ]); const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9:_-]*/; /** Converts tabs to spaces so indentation can be compared by width. */ function indentWidth(line: string): { indent: number; rest: string } { let indent = 0; let index = 0; while (index < line.length) { const char = line[index]; if (char === " ") { indent += 1; } else if (char === "\t") { indent += 2; } else { break; } index += 1; } return { indent, rest: line.slice(index) }; } /** Counts the parentheses that are still open at the end of a line. */ function parenBalance(line: string): number { let balance = 0; let quote: string | null = null; for (let index = 0; index < line.length; index++) { const char = line[index]; if (quote) { if (char === "\\") { index += 1; continue; } if (char === quote) quote = null; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "(") balance += 1; if (char === ")") balance -= 1; } return balance; } interface LogicalLine { indent: number; text: string; line: number; } /** Folds the source into logical lines, joining open attribute lists. */ export function foldLines(source: string): LogicalLine[] { const lines = source.split("\n"); const result: LogicalLine[] = []; let index = 0; while (index < lines.length) { const raw = lines[index] as string; const { indent, rest } = indentWidth(raw); const lineNumber = index + 1; if (rest.trim().length === 0) { index += 1; continue; } let text = rest; while (parenBalance(text) > 0 && index + 1 < lines.length) { index += 1; text += `\n${(lines[index] as string).trim()}`; } result.push({ indent, text, line: lineNumber }); index += 1; } return result; } /** Extracts the Astro style frontmatter of a template. */ export function extractFrontmatter(source: string): { frontmatter: string; body: string; } { const lines = source.split("\n"); let start = 0; while (start < lines.length && (lines[start] as string).trim().length === 0) start += 1; let frontmatter = ""; if (start >= lines.length || (lines[start] as string).trim() !== "---") { return extractScriptBlocks(source, frontmatter); } const body: string[] = []; let index = start + 1; const frontmatterLines: string[] = []; for (; index < lines.length; index++) { const line = lines[index] as string; if (line.trim() === "---") { index += 1; break; } frontmatterLines.push(line); } body.push(...lines.slice(index)); frontmatter = frontmatterLines.join("\n"); return extractScriptBlocks(body.join("\n"), frontmatter); } /** Reads `javascript!` and `typescript!` blocks terminated by `end`. */ export function extractScriptBlocks(source: string, frontmatter = ""): { frontmatter: string; body: string } { const body: string[] = []; const scripts: string[] = []; const lines = source.split("\n"); let index = 0; while (index < lines.length) { const directive = /^\s*(?:javascript|typescript)!\s*$/.test(lines[index] as string); if (!directive) { body.push(lines[index] as string); index += 1; continue; } index += 1; while (index < lines.length && !/^\s*end\s*$/.test(lines[index] as string)) { scripts.push(lines[index] as string); index += 1; } if (index < lines.length) index += 1; } return { frontmatter: [frontmatter, ...scripts].filter(Boolean).join("\n"), body: body.join("\n") }; } /** Finds the index of the `}` matching the `{` at `start`. */ export function findClosingBrace(text: string, start: number): number { let depth = 0; let quote: string | null = null; for (let index = start; index < text.length; index++) { const char = text[index]; if (quote) { if (char === "\\") { index += 1; continue; } if (char === quote) quote = null; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "{") depth += 1; if (char === "}") { depth -= 1; if (depth === 0) return index; } } return -1; } /** Splits text into literal and interpolated parts. */ export function parseTextParts(text: string): TextPart[] { const parts: TextPart[] = []; let literal = ""; let index = 0; const flush = () => { if (literal.length > 0) { parts.push({ type: "literal", value: literal }); literal = ""; } }; while (index < text.length) { const char = text[index]; const next = text[index + 1]; if ((char === "#" || char === "!") && next === "{") { const end = findClosingBrace(text, index + 1); if (end !== -1) { flush(); parts.push({ type: "expression", code: text.slice(index + 2, end).trim(), escaped: char === "#", }); index = end + 1; continue; } } literal += char; index += 1; } flush(); return parts; } /** * `true` when the text after the whitespace starts a new attribute. */ export function startsNewAttribute(source: string, index: number): boolean { const rest = source.slice(index); return /^\s+[A-Za-z_][\w-]*\s*(?:=|,|\)|$)/.test(rest); } /** Parses the attribute list of a tag. */ export function parseAttributes(source: string): RawAttribute[] { const attributes: RawAttribute[] = []; let index = 0; while (index < source.length) { while ( index < source.length && (/\s/.test(source[index] as string) || source[index] === ",") ) { index += 1; } if (index >= source.length) break; const nameMatch = /^[^\s=,()]+/.exec(source.slice(index)); if (!nameMatch) { index += 1; continue; } const name = nameMatch[0]; index += name.length; while (index < source.length && /\s/.test(source[index] as string)) index += 1; if (source[index] !== "=") { attributes.push({ name, kind: "boolean" }); continue; } index += 1; while (index < source.length && /\s/.test(source[index] as string)) index += 1; const quote = source[index]; if (quote === '"' || quote === "'") { const end = source.indexOf(quote, index + 1); const value = end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); attributes.push({ name, kind: "string", value }); index = end === -1 ? source.length : end + 1; continue; } if (quote === "(") { let depth = 0; const start = index; for (; index < source.length; index++) { const char = source[index]; if (char === "(") depth += 1; if (char === ")") { depth -= 1; if (depth === 0) { index += 1; break; } } } attributes.push({ name, kind: "expression", value: source.slice(start + 1, index - 1).trim(), }); continue; } const start = index; let depth = 0; while (index < source.length) { const char = source[index] as string; if (char === "(" || char === "[" || char === "{") depth += 1; if (char === ")" || char === "]" || char === "}") depth -= 1; if (depth === 0 && (char === "," || char === ")")) break; if (depth === 0 && /\s/.test(char) && startsNewAttribute(source, index)) { break; } index += 1; } attributes.push({ name, kind: "expression", value: source.slice(start, index).trim(), }); } return attributes; } /** Parses a tag line into its name, attributes and inline text. */ export function parseTagLine(text: string): { name: string; attributes: RawAttribute[]; text?: string; output?: string; escaped?: boolean; blockText: boolean; anchor?: { name: string; expression?: string }; } { let rest = text; const nameMatch = TAG_NAME.exec(rest); const name = nameMatch ? nameMatch[0] : "div"; if (nameMatch) rest = rest.slice(name.length); const classes: string[] = []; let id: string | undefined; while (rest.length > 0 && (rest[0] === "." || rest[0] === "#")) { const marker = rest[0] as string; const valueMatch = /^[^\s.#(=/!@]+/.exec(rest.slice(1)); if (!valueMatch) break; if (marker === ".") classes.push(valueMatch[0]); else id = valueMatch[0]; rest = rest.slice(1 + valueMatch[0].length); } let anchor: { name: string; expression?: string } | undefined; if (rest.startsWith("@")) { const match = /^@([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)/.exec(rest); if (match) { let length = match[0].length; let expression: string | undefined; // `@docs.{section.id}` keeps the stable group prefix while deriving its // leaf identity from the current composition scope. if (rest.slice(length).startsWith(".{")) { const end = rest.indexOf("}", length + 2); if (end !== -1) { expression = rest.slice(length + 2, end).trim() || undefined; length = end + 1; anchor = { name: `${match[1]}.`, ...(expression ? { expression } : {}) }; } } if (rest[length] === "(") { let depth = 0; let end = length; for (; end < rest.length; end += 1) { if (rest[end] === "(") depth += 1; if (rest[end] === ")") { depth -= 1; if (depth === 0) break; } } if (depth === 0) { expression = rest.slice(length + 1, end).trim() || undefined; length = end + 1; } } anchor ??= { name: match[1] as string, ...(expression ? { expression } : {}) }; rest = rest.slice(length); } } const attributes: RawAttribute[] = []; if (rest.startsWith("(")) { let depth = 0; let end = 0; for (; end < rest.length; end++) { const char = rest[end]; if (char === "(") depth += 1; if (char === ")") { depth -= 1; if (depth === 0) { end += 1; break; } } } attributes.push(...parseAttributes(rest.slice(1, end - 1))); rest = rest.slice(end); } if (id !== undefined) { attributes.unshift({ name: "id", kind: "string", value: id }); } if (classes.length > 0) { attributes.unshift({ name: "class", kind: "string", value: classes.join(" "), }); } const inline = rest.trim(); if (inline === ".") { return { name, attributes, blockText: true, anchor }; } if (inline.startsWith("!=")) { return { name, attributes, output: inline.slice(2).trim(), escaped: false, blockText: false, anchor, }; } if (inline.startsWith("=")) { return { name, attributes, output: inline.slice(1).trim(), escaped: true, blockText: false, anchor, }; } return { name, attributes, text: inline.length > 0 ? inline : undefined, blockText: false, anchor, }; } /** Splits `each value, key in list` into its parts. */ export function parseEach(value: string): { value: string; key?: string; list: string; } { const match = /^([\w$]+)\s*(?:,\s*([\w$]+)\s*)?\s+(?:in|of)\s+(.+)$/.exec( value.trim(), ); if (!match) { throw new Error(`Invalid each expression: ${value}`); } return { value: match[1] as string, key: match[2], list: (match[3] as string).trim(), }; } /** Turns a template into a flat list of tokens. */ export function lex(source: string): Token[] { const { frontmatter, body } = extractFrontmatter(source); const tokens: Token[] = []; if (frontmatter.trim().length > 0) { tokens.push({ type: "frontmatter", indent: 0, line: 1, value: frontmatter, }); } for (const line of foldLines(body)) { const { text, indent, line: lineNumber } = line; const trimmed = text.trim(); if (trimmed.startsWith("//-")) { tokens.push({ type: "comment", indent, line: lineNumber, value: trimmed.slice(3).trim(), silent: true, }); continue; } if (trimmed.startsWith("//")) { tokens.push({ type: "comment", indent, line: lineNumber, value: trimmed.slice(2).trim(), silent: false, }); continue; } if (trimmed.startsWith("!=")) { tokens.push({ type: "out", indent, line: lineNumber, value: trimmed.slice(2).trim(), escaped: false, }); continue; } if (trimmed.startsWith("=")) { tokens.push({ type: "out", indent, line: lineNumber, value: trimmed.slice(1).trim(), escaped: true, }); continue; } if (trimmed.startsWith("- ") || trimmed === "-") { tokens.push({ type: "code", indent, line: lineNumber, value: trimmed === "-" ? "" : trimmed.slice(2).trim(), }); continue; } if (trimmed.startsWith("|")) { tokens.push({ type: "text", indent, line: lineNumber, value: trimmed.slice(1).trim(), }); continue; } if (trimmed === "$$$") { tokens.push({ type: "test", indent, line: lineNumber, value: trimmed, }); continue; } if (/^doctype\b/i.test(trimmed)) { tokens.push({ type: "doctype", indent, line: lineNumber, value: trimmed.slice("doctype".length).trim(), }); continue; } const keywordMatch = /^([a-zA-Z]+)\b\s*(.*)$/.exec(trimmed); if (keywordMatch) { const keyword = keywordMatch[1] as string; const keywordValue = (keywordMatch[2] ?? "").trim(); if (KEYWORDS.has(keyword)) { tokens.push({ type: "keyword", indent, line: lineNumber, value: trimmed, keyword, keywordValue, }); continue; } } if (trimmed.startsWith("+")) { const callMatch = /^\+([a-zA-Z_$][\w$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec( trimmed, ); if (callMatch) { tokens.push({ type: "keyword", indent, line: lineNumber, value: trimmed, keyword: "mixin-call", keywordValue: callMatch[1] as string, text: (callMatch[2] ?? "").trim(), }); continue; } } const tag = parseTagLine(text); tokens.push({ type: "tag", indent, line: lineNumber, value: trimmed, name: tag.name, attributes: tag.attributes, text: tag.text, output: tag.output, escaped: tag.escaped, blockText: tag.blockText, anchor: tag.anchor, }); } return tokens; }