muddle/views/engine/lexer.ts
2026-10-23 08:53:07 -04:00

616 lines
14 KiB
TypeScript

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;
}