490 lines
11 KiB
TypeScript
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;
|
|
}
|