616 lines
14 KiB
TypeScript
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;
|
|
}
|