747 lines
22 KiB
TypeScript
747 lines
22 KiB
TypeScript
/**
|
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
|
*
|
|
* Grammar (subset of the vision, but real):
|
|
*
|
|
* page <Name> {
|
|
* state <ident> = <expr> // zero or more
|
|
* view { <html> } // plain HTML (see parseHtmlView)
|
|
* seo { title = "Home" description = "..." }
|
|
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
|
* client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
|
* style { <raw css> } // zero or more, inlined with the page
|
|
* functions { <raw js> } // zero or more, shared helpers
|
|
* api <METHOD> <path> { <raw js> } // zero or more
|
|
* realtime <name> { on <evt>(<args>) { <raw js> } * } // zero or more
|
|
* }
|
|
*
|
|
* The `view` block is written as ordinary HTML — nothing new to learn. Text may
|
|
* contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and
|
|
* `@event="..."` declares a client event binding. See `parseHtmlView`.
|
|
*/
|
|
|
|
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
|
|
|
export interface StateDecl {
|
|
name: string;
|
|
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
|
expr: string;
|
|
}
|
|
|
|
export interface Attr {
|
|
name: string;
|
|
value: string;
|
|
/** True for `@event` bindings (vs. plain HTML attributes). */
|
|
event: boolean;
|
|
/** True for a valueless boolean attribute, e.g. `<button disabled>`. */
|
|
boolean?: boolean;
|
|
}
|
|
|
|
/**
|
|
* HTML void elements: they have no children and no closing tag.
|
|
* @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
|
|
*/
|
|
export const VOID_ELEMENTS = new Set([
|
|
"area",
|
|
"base",
|
|
"br",
|
|
"col",
|
|
"embed",
|
|
"hr",
|
|
"img",
|
|
"input",
|
|
"link",
|
|
"meta",
|
|
"param",
|
|
"source",
|
|
"track",
|
|
"wbr",
|
|
]);
|
|
|
|
export type ViewNode =
|
|
| { type: "text"; value: string }
|
|
| { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
|
|
/**
|
|
* A server-side loop: `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`.
|
|
* `list` is a JS expression (evaluated on the server, may reference an `ssr` data
|
|
* binding). The `body` is rendered once per item with `{item.field}` interpolation;
|
|
* `empty` renders when the list is empty. See codegen `compileEach`.
|
|
*/
|
|
| {
|
|
type: "each";
|
|
list: string;
|
|
item: string;
|
|
index?: string;
|
|
body: ViewNode[];
|
|
empty: ViewNode[];
|
|
}
|
|
/**
|
|
* A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
|
|
* Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
|
|
* (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
|
|
*/
|
|
| { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };
|
|
|
|
export interface ApiBlock {
|
|
method: string;
|
|
path: string;
|
|
body: string;
|
|
}
|
|
|
|
export type SeoBlock = Record<string, string>;
|
|
|
|
export type DataMode = "ssr" | "client";
|
|
|
|
export interface DataApiBlock {
|
|
mode: DataMode;
|
|
name: string;
|
|
method: string;
|
|
path: string;
|
|
body: string;
|
|
}
|
|
|
|
export interface ModeFunctionsBlock {
|
|
mode: DataMode;
|
|
body: string;
|
|
}
|
|
|
|
export type LifecycleHookName = "mount" | "update" | "unmount";
|
|
|
|
export interface LifecycleBlock {
|
|
mount?: string;
|
|
update?: string;
|
|
unmount?: string;
|
|
}
|
|
|
|
export interface WatchBlock {
|
|
state: string;
|
|
body: string;
|
|
}
|
|
|
|
export interface RealtimeHandler {
|
|
event: string;
|
|
args: string[];
|
|
body: string;
|
|
}
|
|
|
|
export interface RealtimeBlock {
|
|
name: string;
|
|
handlers: RealtimeHandler[];
|
|
}
|
|
|
|
export interface PropDecl {
|
|
name: string;
|
|
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
|
default: string;
|
|
}
|
|
|
|
export interface PageAst {
|
|
type: "page";
|
|
/**
|
|
* `page` is a route, `component` is a reusable fragment,
|
|
* and `layout` is a reusable page wrapper.
|
|
*/
|
|
kind: "page" | "component" | "layout";
|
|
name: string;
|
|
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
|
|
layout?: string;
|
|
/** Declared component props (empty for pages). */
|
|
props: PropDecl[];
|
|
states: StateDecl[];
|
|
seo: SeoBlock;
|
|
view: ViewNode[];
|
|
styles: string[];
|
|
functions: string[];
|
|
dataApis: DataApiBlock[];
|
|
modeFunctions: ModeFunctionsBlock[];
|
|
lifecycle: LifecycleBlock;
|
|
watches: WatchBlock[];
|
|
apis: ApiBlock[];
|
|
realtimes: RealtimeBlock[];
|
|
}
|
|
|
|
export class ParseError extends Error {}
|
|
|
|
function parseSeoBlock(body: string): SeoBlock {
|
|
const out: SeoBlock = {};
|
|
const pair =
|
|
/([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g;
|
|
for (const match of body.matchAll(pair)) {
|
|
const key = match[1]!;
|
|
const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
|
|
out[key] = unescapeSeoValue(rawValue.trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function unescapeSeoValue(value: string): string {
|
|
return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
|
|
if (ch === "n") return "\n";
|
|
if (ch === "r") return "\r";
|
|
if (ch === "t") return "\t";
|
|
return ch;
|
|
});
|
|
}
|
|
|
|
export function parse(source: string): PageAst {
|
|
const lx = new Lexer(source);
|
|
|
|
const expect = (type: Token["type"]): Token => {
|
|
const t = lx.next();
|
|
if (t.type !== type) {
|
|
throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`);
|
|
}
|
|
return t;
|
|
};
|
|
const expectKeyword = (kw: string): void => {
|
|
const t = lx.next();
|
|
if (t.type !== "ident" || t.value !== kw) {
|
|
throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`);
|
|
}
|
|
};
|
|
|
|
try {
|
|
// A file may contain a page, reusable component, or reusable layout.
|
|
const opener = lx.next();
|
|
|
|
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
|
|
throw new ParseError(
|
|
`Expected 'page', 'component', or 'layout' but got '${
|
|
opener.value || opener.type
|
|
}' at offset ${opener.pos}`,
|
|
);
|
|
}
|
|
|
|
const kind = opener.value as "page" | "component" | "layout";
|
|
const name = expect("ident").value;
|
|
expect("lbrace");
|
|
|
|
let layout: string | undefined;
|
|
const props: PropDecl[] = [];
|
|
const states: StateDecl[] = [];
|
|
const seo: SeoBlock = {};
|
|
const view: ViewNode[] = [];
|
|
const styles: string[] = [];
|
|
const functions: string[] = [];
|
|
const dataApis: DataApiBlock[] = [];
|
|
const modeFunctions: ModeFunctionsBlock[] = [];
|
|
const lifecycle: LifecycleBlock = {};
|
|
const watches: WatchBlock[] = [];
|
|
const apis: ApiBlock[] = [];
|
|
const realtimes: RealtimeBlock[] = [];
|
|
|
|
while (lx.peek().type !== "rbrace") {
|
|
const kw = lx.peek();
|
|
if (kw.type === "eof") throw new ParseError(`Unexpected end of input inside ${kind}`);
|
|
if (kw.type !== "ident") {
|
|
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
|
|
}
|
|
switch (kw.value) {
|
|
case "layout": {
|
|
// layout = "public" — selects app/layouts/<name>.wrn for this page.
|
|
lx.next();
|
|
expect("eq");
|
|
layout = expect("string").value;
|
|
break;
|
|
}
|
|
case "props": {
|
|
// props { name = <default> ... } — one declaration per line.
|
|
lx.next();
|
|
expect("lbrace");
|
|
while (lx.peek().type !== "rbrace") {
|
|
const t = lx.peek();
|
|
if (t.type === "eof") throw new ParseError("Unexpected end of input inside props");
|
|
if (t.type !== "ident") {
|
|
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
|
}
|
|
const pName = expect("ident").value;
|
|
expect("eq");
|
|
props.push({ name: pName, default: lx.readToLineEnd() });
|
|
}
|
|
expect("rbrace");
|
|
break;
|
|
}
|
|
case "state": {
|
|
lx.next();
|
|
const sName = expect("ident").value;
|
|
expect("eq");
|
|
states.push({ name: sName, expr: lx.readToLineEnd() });
|
|
break;
|
|
}
|
|
case "view": {
|
|
lx.next();
|
|
expect("lbrace");
|
|
// The view body is plain HTML. Parse it straight off the source
|
|
// (the token lexer isn't used for markup), then resume after the
|
|
// block's closing `}`.
|
|
const { nodes, endPos } = parseHtmlView(lx.src, lx.pos);
|
|
view.push(...nodes);
|
|
lx.pos = endPos;
|
|
expect("rbrace");
|
|
break;
|
|
}
|
|
case "seo": {
|
|
lx.next();
|
|
Object.assign(seo, parseSeoBlock(lx.readBalancedBraces()));
|
|
break;
|
|
}
|
|
case "api": {
|
|
lx.next();
|
|
const method = expect("ident").value.toUpperCase();
|
|
const path = lx.readPath();
|
|
const body = lx.readBalancedBraces();
|
|
apis.push({ method, path, body });
|
|
break;
|
|
}
|
|
case "ssr":
|
|
case "client": {
|
|
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
|
|
lx.next();
|
|
expect("lbrace");
|
|
while (lx.peek().type !== "rbrace") {
|
|
const member = lx.peek();
|
|
if (member.type === "eof") {
|
|
throw new ParseError(`Unexpected end of input inside ${mode} block`);
|
|
}
|
|
if (member.type !== "ident") {
|
|
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
|
|
}
|
|
switch (member.value) {
|
|
case "api": {
|
|
lx.next();
|
|
const name = expect("ident").value;
|
|
const method = expect("ident").value.toUpperCase();
|
|
const path = lx.readPath();
|
|
const body = lx.readBalancedBraces();
|
|
dataApis.push({ mode, name, method, path, body });
|
|
break;
|
|
}
|
|
case "functions": {
|
|
lx.next();
|
|
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
|
|
break;
|
|
}
|
|
default:
|
|
throw new ParseError(
|
|
`Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
|
|
);
|
|
}
|
|
}
|
|
expect("rbrace");
|
|
break;
|
|
}
|
|
case "realtime": {
|
|
lx.next();
|
|
const rName = expect("ident").value;
|
|
expect("lbrace");
|
|
const handlers: RealtimeHandler[] = [];
|
|
while (lx.peek().type !== "rbrace") {
|
|
expectKeyword("on");
|
|
const event = expect("ident").value;
|
|
expect("lparen");
|
|
const args: string[] = [];
|
|
while (lx.peek().type !== "rparen") {
|
|
args.push(expect("ident").value);
|
|
if (lx.peek().type === "comma") lx.next();
|
|
}
|
|
expect("rparen");
|
|
handlers.push({ event, args, body: lx.readBalancedBraces() });
|
|
}
|
|
expect("rbrace");
|
|
realtimes.push({ name: rName, handlers });
|
|
break;
|
|
}
|
|
case "style": {
|
|
lx.next();
|
|
styles.push(lx.readBalancedBraces());
|
|
break;
|
|
}
|
|
case "lifecycle": {
|
|
lx.next();
|
|
expect("lbrace");
|
|
|
|
while (lx.peek().type !== "rbrace") {
|
|
const hook = lx.peek();
|
|
|
|
if (hook.type === "eof") {
|
|
throw new ParseError("Unexpected end of input inside lifecycle block");
|
|
}
|
|
|
|
if (hook.type !== "ident") {
|
|
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
|
|
}
|
|
|
|
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
|
|
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
|
|
}
|
|
|
|
const hookName = hook.value as LifecycleHookName;
|
|
|
|
lx.next();
|
|
|
|
if (lifecycle[hookName] !== undefined) {
|
|
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
|
|
}
|
|
|
|
lifecycle[hookName] = lx.readBalancedBraces();
|
|
}
|
|
|
|
expect("rbrace");
|
|
break;
|
|
}
|
|
case "watch": {
|
|
lx.next();
|
|
|
|
const stateName = expect("ident").value;
|
|
const body = lx.readBalancedBraces();
|
|
|
|
watches.push({
|
|
state: stateName,
|
|
body,
|
|
});
|
|
|
|
break;
|
|
}
|
|
case "functions": {
|
|
lx.next();
|
|
functions.push(lx.readBalancedBraces());
|
|
break;
|
|
}
|
|
default:
|
|
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
|
|
}
|
|
}
|
|
expect("rbrace");
|
|
|
|
const declaredStates = new Set(states.map((state) => state.name));
|
|
|
|
for (const watcher of watches) {
|
|
if (!declaredStates.has(watcher.state)) {
|
|
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: "page",
|
|
kind,
|
|
name,
|
|
layout,
|
|
props,
|
|
states,
|
|
seo,
|
|
view,
|
|
styles,
|
|
functions,
|
|
dataApis,
|
|
modeFunctions,
|
|
lifecycle,
|
|
watches,
|
|
apis,
|
|
realtimes,
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof LexError) throw new ParseError(err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the body of a `view { ... }` block as plain HTML.
|
|
*
|
|
* `src` is the whole `.wrn` source; `pos` points just past the view block's
|
|
* opening `{`. Returns the parsed nodes plus the index of the block's closing
|
|
* `}` (left for the caller to consume). It is intentionally lenient — you write
|
|
* markup the way you already know:
|
|
*
|
|
* - `<tag attr="v" @event="expr">children</tag>` — elements with attributes
|
|
* - `<tag/>` and HTML void elements (`<br>`, `<img>`, …) — no closing tag
|
|
* - text may contain `{expr}` interpolation, kept verbatim for the runtime
|
|
* - `@event="..."` becomes a client event binding; hyphenated names are fine
|
|
* - `<!-- comments -->` are dropped
|
|
*
|
|
* `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a
|
|
* tag is treated as literal text.
|
|
*/
|
|
export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; endPos: number } {
|
|
let i = pos;
|
|
|
|
const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
|
|
|
const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c);
|
|
|
|
const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
|
|
|
|
const fail = (msg: string): never => {
|
|
throw new ParseError(`${msg} at offset ${i}`);
|
|
};
|
|
const skipWs = (): void => {
|
|
while (i < src.length && isWs(src[i]!)) i++;
|
|
};
|
|
|
|
/** Read a `{...}` interpolation (brace-balanced), braces included. */
|
|
const readInterpolation = (): string => {
|
|
const start = i;
|
|
let depth = 0;
|
|
for (; i < src.length; i++) {
|
|
if (src[i] === "{") depth++;
|
|
else if (src[i] === "}" && --depth === 0) {
|
|
i++;
|
|
return src.slice(start, i);
|
|
}
|
|
}
|
|
return fail("Unterminated `{` interpolation in view");
|
|
};
|
|
|
|
const readQuoted = (): string => {
|
|
const quote = src[i];
|
|
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
|
|
i++;
|
|
const start = i;
|
|
while (i < src.length && src[i] !== quote) i++;
|
|
if (i >= src.length) return fail("Unterminated attribute value");
|
|
const value = src.slice(start, i);
|
|
i++; // closing quote
|
|
return value;
|
|
};
|
|
|
|
const readTagName = (): string => {
|
|
if (i >= src.length || !isNameStart(src[i]!)) {
|
|
return fail("Expected a tag name");
|
|
}
|
|
|
|
const start = i++;
|
|
|
|
while (i < src.length && isTagNamePart(src[i]!)) {
|
|
i++;
|
|
}
|
|
|
|
return src.slice(start, i);
|
|
};
|
|
|
|
const readAttributeName = (): string => {
|
|
if (i >= src.length) {
|
|
return fail("Expected an attribute name");
|
|
}
|
|
|
|
const start = i;
|
|
|
|
while (i < src.length) {
|
|
const char = src[i];
|
|
const next = src[i + 1];
|
|
|
|
if (
|
|
char === "=" ||
|
|
char === ">" ||
|
|
char === '"' ||
|
|
char === "'" ||
|
|
char === " " ||
|
|
char === "\t" ||
|
|
char === "\n" ||
|
|
char === "\r" ||
|
|
(char === "/" && next === ">")
|
|
) {
|
|
break;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
if (i === start) {
|
|
return fail("Expected an attribute name");
|
|
}
|
|
|
|
return src.slice(start, i);
|
|
};
|
|
|
|
const parseTag = (): ViewNode => {
|
|
i++; // consume '<'
|
|
const tag = readTagName();
|
|
const attrs: Attr[] = [];
|
|
|
|
for (;;) {
|
|
skipWs();
|
|
const c = src[i];
|
|
if (c === undefined) return fail(`Unterminated <${tag}> tag`);
|
|
if (c === ">") {
|
|
i++;
|
|
break;
|
|
}
|
|
if (c === "/" && src[i + 1] === ">") {
|
|
i += 2;
|
|
return { type: "element", tag, attrs, children: [] };
|
|
}
|
|
if (c === "@") {
|
|
i++;
|
|
const name = readAttributeName();
|
|
skipWs();
|
|
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
|
|
i++;
|
|
skipWs();
|
|
attrs.push({ name, value: readQuoted(), event: true });
|
|
continue;
|
|
}
|
|
const name = readAttributeName();
|
|
skipWs();
|
|
if (src[i] === "=") {
|
|
i++;
|
|
skipWs();
|
|
attrs.push({ name, value: readQuoted(), event: false });
|
|
} else {
|
|
attrs.push({ name, value: "", event: false, boolean: true });
|
|
}
|
|
}
|
|
|
|
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
|
|
return { type: "element", tag, attrs, children: [] };
|
|
}
|
|
|
|
const children = parseNodeList("element");
|
|
// parseNodeList stops at the parent's closing tag `</`.
|
|
if (src[i] !== "<" || src[i + 1] !== "/") return fail(`Expected </${tag}>`);
|
|
i += 2;
|
|
skipWs();
|
|
const close = readTagName();
|
|
if (close !== tag) return fail(`Mismatched </${close}>, expected </${tag}>`);
|
|
skipWs();
|
|
if (src[i] !== ">") return fail(`Expected '>' to close </${tag}>`);
|
|
i++;
|
|
return { type: "element", tag, attrs, children };
|
|
};
|
|
|
|
const EACH_HEADER =
|
|
/^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\}$/;
|
|
|
|
/** Parse `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`. */
|
|
function parseEach(): ViewNode {
|
|
const header = readInterpolation(); // reads the full `{#each …}`
|
|
const m = EACH_HEADER.exec(header);
|
|
if (!m) return fail(`Invalid {#each …} header: ${header}`);
|
|
const list = m[1]!.trim();
|
|
const item = m[2]!;
|
|
const index = m[3];
|
|
const body = parseNodeList("each"); // stops at {:empty} or {/each}
|
|
let empty: ViewNode[] = [];
|
|
if (src.startsWith("{:empty}", i)) {
|
|
i += "{:empty}".length;
|
|
empty = parseNodeList("each"); // stops at {/each}
|
|
}
|
|
if (!src.startsWith("{/each}", i)) return fail("Expected `{/each}` to close `{#each}`");
|
|
i += "{/each}".length;
|
|
return { type: "each", list, item, index, body, empty };
|
|
}
|
|
|
|
/** Parse `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`. */
|
|
function parseIf(): ViewNode {
|
|
const header = readInterpolation(); // reads the full `{#if …}`
|
|
const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header);
|
|
if (!m) return fail(`Invalid {#if …} header: ${header}`);
|
|
const branches: { cond: string | null; body: ViewNode[] }[] = [
|
|
{ cond: m[1]!.trim(), body: parseNodeList("if") },
|
|
];
|
|
for (;;) {
|
|
if (src.startsWith("{:else if", i)) {
|
|
const h = readInterpolation();
|
|
const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h);
|
|
if (!mm) return fail(`Invalid {:else if …}: ${h}`);
|
|
branches.push({ cond: mm[1]!.trim(), body: parseNodeList("if") });
|
|
continue;
|
|
}
|
|
if (src.startsWith("{:else}", i)) {
|
|
i += "{:else}".length;
|
|
branches.push({ cond: null, body: parseNodeList("if") });
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (!src.startsWith("{/if}", i)) return fail("Expected `{/if}` to close `{#if}`");
|
|
i += "{/if}".length;
|
|
return { type: "if", branches };
|
|
}
|
|
|
|
/**
|
|
* Parse a run of nodes. `mode` sets the terminator:
|
|
* - "root": stops at the view block's closing `}`
|
|
* - "element": stops at the parent element's closing tag (`</`)
|
|
* - "each": stops (without consuming) at `{:empty}` or `{/each}`
|
|
* - "if": stops (without consuming) at `{:else …}` or `{/if}`
|
|
* `{#each …}` and `{#if …}` start nested blocks in any mode.
|
|
*/
|
|
function parseNodeList(mode: "root" | "element" | "each" | "if"): ViewNode[] {
|
|
const nodes: ViewNode[] = [];
|
|
let text = "";
|
|
const flush = (): void => {
|
|
if (text.length > 0) {
|
|
nodes.push({ type: "text", value: text });
|
|
text = "";
|
|
}
|
|
};
|
|
|
|
for (;;) {
|
|
if (i >= src.length) {
|
|
return mode === "root"
|
|
? fail("Unexpected end of view (missing `}`)")
|
|
: fail("Unclosed block");
|
|
}
|
|
const c = src[i]!;
|
|
|
|
if (c === "<") {
|
|
const next = src[i + 1];
|
|
if (next === "/") {
|
|
flush();
|
|
break; // parent's closing tag
|
|
}
|
|
if (src.startsWith("<!--", i)) {
|
|
const end = src.indexOf("-->", i + 4);
|
|
i = end === -1 ? src.length : end + 3;
|
|
continue;
|
|
}
|
|
if (next !== undefined && (isNameStart(next) || next === "!")) {
|
|
flush();
|
|
nodes.push(parseTag());
|
|
continue;
|
|
}
|
|
// A lone `<` that doesn't start a tag: treat as literal text.
|
|
text += c;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c === "{") {
|
|
if (src.startsWith("{#each", i)) {
|
|
flush();
|
|
nodes.push(parseEach());
|
|
continue;
|
|
}
|
|
if (src.startsWith("{#if", i)) {
|
|
flush();
|
|
nodes.push(parseIf());
|
|
continue;
|
|
}
|
|
if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) {
|
|
flush();
|
|
break; // loop-section terminator; left for parseEach
|
|
}
|
|
if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) {
|
|
flush();
|
|
break; // conditional-section terminator; left for parseIf
|
|
}
|
|
text += readInterpolation();
|
|
continue;
|
|
}
|
|
|
|
if (c === "}" && mode === "root") {
|
|
flush();
|
|
break; // view terminator; leave `}` for the caller
|
|
}
|
|
|
|
text += c;
|
|
i++;
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
const nodes = parseNodeList("root");
|
|
return { nodes, endPos: i };
|
|
}
|