import { parseEach, parseTextParts } from "./lexer.ts"; import type { CaseNode, IfBranch, Node, PropDeclaration, Token, } from "./types.ts"; interface TokenNode { token: Token; children: TokenNode[]; } /** Builds the indentation tree of the token list. */ export function buildTree(tokens: Token[]): TokenNode[] { const root: TokenNode[] = []; const stack: { indent: number; nodes: TokenNode[] }[] = [ { indent: -1, nodes: root }, ]; for (const token of tokens) { while ( stack.length > 1 && token.indent <= (stack[stack.length - 1] as { indent: number }).indent ) { stack.pop(); } const current = stack[stack.length - 1] as { indent: number; nodes: TokenNode[]; }; const node: TokenNode = { token, children: [] }; current.nodes.push(node); stack.push({ indent: token.indent, nodes: node.children }); } return root; } /** * Splits a type into the type and the default value. */ export function splitDefault(value: string): { type: string; default?: string; } { for (let index = 0; index < value.length; index += 1) { if (value[index] !== "=") continue; const next = value[index + 1]; const previous = value[index - 1]; if (next === "=" || next === ">") { index += 1; continue; } if ( previous === "=" || previous === "!" || previous === "<" || previous === ">" ) { continue; } return { type: value.slice(0, index).trim(), default: value.slice(index + 1).trim(), }; } return { type: value.trim() }; } function parseProps(children: TokenNode[]): PropDeclaration[] { const props: PropDeclaration[] = []; for (const child of children) { const token = child.token; if (token.type !== "tag" && token.type !== "text") continue; const rawName = (token.name ?? token.value).replace(/:$/, "").trim(); if (!rawName) continue; let optional = rawName.endsWith("?"); const name = optional ? rawName.slice(0, -1) : rawName; let rest = (token.text ?? "").trim(); if (rest.startsWith("?")) { optional = true; rest = rest.slice(1).trim(); } if (rest.startsWith(":")) { rest = rest.slice(1).trim(); } const split = splitDefault(rest); const type = split.type; const defaultValue = split.default; props.push({ name, type: type.length > 0 ? type : "unknown", optional, defaultValue, }); } return props; } function parseComponentProps(children: TokenNode[]): Record { const props: Record = {}; for (const child of children) { const token = child.token; if (token.type !== "tag" && token.type !== "text") continue; const name = (token.name ?? token.value).replace(/:$/, "").trim(); if (!name) continue; props[name] = (token.text ?? "").trim(); } return props; } function transform(tokenNodes: TokenNode[]): Node[] { const nodes: Node[] = []; let index = 0; while (index < tokenNodes.length) { const entry = tokenNodes[index] as TokenNode; const token = entry.token; switch (token.type) { case "tag": { let children = transform(entry.children); if (token.output !== undefined) { children = [ { kind: "out", code: token.output, escaped: token.escaped !== false, }, ...children, ]; } if (token.text !== undefined && token.text.length > 0) { children = [ { kind: "text", parts: parseTextParts(token.text) }, ...children, ]; } if (token.blockText) { const raw = entry.children .map((child) => child.token.value.trim()) .join("\n"); children = [{ kind: "text", parts: parseTextParts(raw) }]; } nodes.push({ kind: "element", name: token.name ?? "div", attributes: token.attributes ?? [], children, selfClosing: false, anchor: token.anchor, }); index += 1; break; } case "text": { nodes.push({ kind: "text", parts: parseTextParts(token.value) }); index += 1; break; } case "code": { nodes.push({ kind: "code", code: token.value }); index += 1; break; } case "out": { nodes.push({ kind: "out", code: token.value, escaped: token.escaped !== false, }); index += 1; break; } case "comment": { nodes.push({ kind: "comment", value: token.value ?? "", silent: token.silent === true, children: transform(entry.children), }); index += 1; break; } case "test": { nodes.push({ kind: "test", code: entry.children.map((child) => child.token.value).join("\n"), }); index += 1; break; } case "doctype": { nodes.push({ kind: "doctype", value: token.value }); index += 1; break; } case "keyword": { const keyword = token.keyword ?? ""; if (keyword === "if" || keyword === "unless") { const branches: IfBranch[] = [ { test: token.keywordValue ?? "", negate: keyword === "unless", children: transform(entry.children), }, ]; let cursor = index + 1; while (cursor < tokenNodes.length) { const next = (tokenNodes[cursor] as TokenNode).token; if (next.keyword !== "else") break; const value = (next.keywordValue ?? "").trim(); if (value.startsWith("if")) { branches.push({ test: value.slice(2).trim(), negate: false, children: transform((tokenNodes[cursor] as TokenNode).children), }); } else { branches.push({ test: null, negate: false, children: transform((tokenNodes[cursor] as TokenNode).children), }); } cursor += 1; } nodes.push({ kind: "if", branches }); index = cursor; break; } if (keyword === "each") { const parsed = parseEach(token.keywordValue ?? ""); nodes.push({ kind: "each", value: parsed.value, key: parsed.key, list: parsed.list, children: transform(entry.children), }); index += 1; break; } if (keyword === "while") { nodes.push({ kind: "while", test: token.keywordValue ?? "", children: transform(entry.children), }); index += 1; break; } if (keyword === "case") { const cases: { test?: string; children: Node[] }[] = []; for (const child of entry.children) { const childToken = child.token; if (childToken.keyword === "when") { cases.push({ test: childToken.keywordValue ?? "", children: transform(child.children), }); } else if (childToken.keyword === "default") { cases.push({ children: transform(child.children) }); } } const caseNode: CaseNode = { kind: "case", subject: token.keywordValue ?? "", cases, }; nodes.push(caseNode); index += 1; break; } if (keyword === "mixin") { const definition = /^([\w$-]+)\s*\(([\s\S]*)\)\s*$/.exec( (token.keywordValue ?? "").trim(), ); nodes.push({ kind: "mixin", name: definition ? (definition[1] as string) : (token.keywordValue ?? "").trim(), params: definition ? (definition[2] as string).trim() : "", children: transform(entry.children), }); index += 1; break; } if (keyword === "mixin-call") { nodes.push({ kind: "mixin-call", name: token.keywordValue ?? "", args: token.text ?? "", children: transform(entry.children), }); index += 1; break; } if (keyword === "include") { nodes.push({ kind: "include", path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), children: [], }); index += 1; break; } if (keyword === "extends") { nodes.push({ kind: "extends", path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), }); index += 1; break; } if ( keyword === "block" || keyword === "append" || keyword === "prepend" ) { nodes.push({ kind: "block", name: (token.keywordValue ?? "default").trim(), mode: keyword === "append" ? "append" : keyword === "prepend" ? "prepend" : "replace", children: transform(entry.children), }); index += 1; break; } if (keyword === "slot") { nodes.push({ kind: "slot", name: (token.keywordValue ?? "default").trim(), children: transform(entry.children), }); index += 1; break; } if (keyword === "yield") { nodes.push({ kind: "yield", name: (token.keywordValue ?? "default").trim(), }); index += 1; break; } if (keyword === "portal") { nodes.push({ kind: "portal", name: (token.keywordValue ?? "default") .trim() .replace(/^['"]|['"]$/g, ""), children: transform(entry.children), }); index += 1; break; } if (keyword === "props") { nodes.push({ kind: "props", props: parseProps(entry.children) }); index += 1; break; } if (keyword === "component" || keyword === "island") { const props: Record = {}; const renders: { name: string; children: Node[] }[] = []; for (const child of entry.children) { if (child.token.keyword === "render") { renders.push({ name: (child.token.keywordValue ?? "").trim(), children: transform(child.children), }); continue; } const assignment = parseComponentProps([child]); Object.assign(props, assignment); } nodes.push({ kind: keyword, name: (token.keywordValue ?? "").trim(), props, renders, }); index += 1; break; } index += 1; break; } default: index += 1; break; } } return nodes; } export interface ParsedTemplate { nodes: Node[]; frontmatter: string; } /** Parses a template into its AST. */ export function parse(tokens: Token[]): ParsedTemplate { const frontmatterToken = tokens.find((token) => token.type === "frontmatter"); const rest = tokens.filter((token) => token.type !== "frontmatter"); return { nodes: transform(buildTree(rest)), frontmatter: frontmatterToken?.value ?? "", }; } /** Collects every `props` block of a template. */ export function collectProps(nodes: Node[]): PropDeclaration[] { const props: PropDeclaration[] = []; for (const node of nodes) { if (node.kind === "props") { props.push(...node.props); } } return props; } /** Collects every named block of a template. */ export function collectBlocks( nodes: Node[], ): Map { const blocks = new Map< string, { mode: "replace" | "append" | "prepend"; children: Node[] } >(); for (const node of nodes) { if (node.kind === "block") { blocks.set(node.name, { mode: node.mode, children: node.children }); } } return blocks; } /** Collects every mixin definition of a template. */ export function collectMixins( nodes: Node[], ): { name: string; params: string; children: Node[] }[] { const mixins: { name: string; params: string; children: Node[] }[] = []; for (const node of nodes) { if (node.kind === "mixin") { mixins.push({ name: node.name, params: node.params, children: node.children, }); } } return mixins; }