muddle/views/engine/parser.ts
2026-10-23 09:12:49 -04:00

490 lines
11 KiB
TypeScript

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<string, string> {
const props: Record<string, string> = {};
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<string, string> = {};
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<string, { mode: "replace" | "append" | "prepend"; children: Node[] }> {
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;
}