1288 lines
40 KiB
TypeScript
1288 lines
40 KiB
TypeScript
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
|
|
|
/**
|
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
|
*
|
|
* Grammar (subset of the vision, but real):
|
|
*
|
|
* page <Name> {
|
|
* types { <TypeScript declarations> }
|
|
* props { <ident>: <type> [= <expr>] } // no default means required
|
|
* state <ident>: <type> = <expr> // type annotation is optional
|
|
* 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";
|
|
import { validateTypedInitializer } from "./types.ts";
|
|
import {
|
|
parseComputedDeclarations,
|
|
parseOutputs,
|
|
parsePersist,
|
|
parseRuntimeFunctions,
|
|
parseStateDeclarations,
|
|
parseStoreLifecycle,
|
|
parseStructuredImports,
|
|
type OutputDecl,
|
|
type PersistDecl,
|
|
type RuntimeFunctionDecl,
|
|
type StateRuntime,
|
|
type StoreKind,
|
|
type StoreLifecycleDecl,
|
|
type StructuredImportDecl,
|
|
} from "./v060.ts";
|
|
|
|
export interface StateDecl {
|
|
name: string;
|
|
/** Runtime visibility. Legacy declarations are shared. */
|
|
runtime: StateRuntime;
|
|
/** Explicit TypeScript-style type annotation, when supplied. */
|
|
valueType?: string;
|
|
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
|
expr: string;
|
|
}
|
|
|
|
export interface ComputedDecl {
|
|
name: string;
|
|
valueType?: string;
|
|
expr: string;
|
|
}
|
|
|
|
export interface EffectBlock {
|
|
body: string;
|
|
}
|
|
|
|
export interface LoadBlock {
|
|
mode: "server" | "client";
|
|
name?: string;
|
|
dependsOn?: string[];
|
|
deferred?: boolean;
|
|
body: string;
|
|
}
|
|
|
|
export interface ActionBlock {
|
|
name: string;
|
|
args: string[];
|
|
schema?: string;
|
|
body: 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>] [key <expr>]} …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;
|
|
key?: 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;
|
|
/** Explicit TypeScript-style type annotation, when supplied. */
|
|
valueType?: string;
|
|
/** Props without a default are required. */
|
|
required: boolean;
|
|
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
|
default: string;
|
|
}
|
|
|
|
export interface EventDecl {
|
|
/** Legacy public event declaration retained for compatibility. */
|
|
name: string;
|
|
}
|
|
|
|
export interface PageAst {
|
|
type: "page";
|
|
/** Static ES module imports declared before the WRN root declaration. */
|
|
imports: string[];
|
|
structuredImports: StructuredImportDecl[];
|
|
/**
|
|
* `page` is a route, `component` is a reusable fragment,
|
|
* and `layout` is a reusable page wrapper.
|
|
*/
|
|
kind: "page" | "component" | "layout" | "global-store" | "page-store";
|
|
storeKind?: StoreKind;
|
|
name: string;
|
|
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
|
|
layout?: string;
|
|
layoutIsSymbol?: boolean;
|
|
/** Execution boundary metadata. Defaults to universal. */
|
|
runtime?: "server" | "client" | "universal" | "edge" | "worker" | "service-worker";
|
|
/** Explicit rendering policy; `hybrid` is the default SSR + optional hydration behavior. */
|
|
renderMode?: "static" | "server" | "hybrid" | "client" | "partial-static";
|
|
/** Client hydration strategy. Defaults to load when interactivity is present. */
|
|
hydrate?: string;
|
|
/** Declarative framework cache policy. */
|
|
cache?: Record<string, string>;
|
|
/** Declared component props (empty for pages). */
|
|
props: PropDecl[];
|
|
/** Legacy public events exposed by a reusable component. */
|
|
events: EventDecl[];
|
|
/** Canonical typed callable outputs. */
|
|
outputs: OutputDecl[];
|
|
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
|
types: string[];
|
|
states: StateDecl[];
|
|
computed: ComputedDecl[];
|
|
effects: EffectBlock[];
|
|
loads: LoadBlock[];
|
|
actions: ActionBlock[];
|
|
security: Record<string, string>;
|
|
navigation: Record<string, string>;
|
|
seo: SeoBlock;
|
|
view: ViewNode[];
|
|
styles: string[];
|
|
functions: string[];
|
|
runtimeFunctions: RuntimeFunctionDecl[];
|
|
dataApis: DataApiBlock[];
|
|
modeFunctions: ModeFunctionsBlock[];
|
|
lifecycle: LifecycleBlock;
|
|
storeLifecycle: StoreLifecycleDecl;
|
|
persist?: PersistDecl;
|
|
watches: WatchBlock[];
|
|
apis: ApiBlock[];
|
|
realtimes: RealtimeBlock[];
|
|
}
|
|
|
|
export class ParseError extends Error {
|
|
readonly code: string;
|
|
readonly offset?: number;
|
|
|
|
constructor(message: string, code = "WRN-PARSE-001") {
|
|
super(message);
|
|
this.name = "ParseError";
|
|
this.code = code;
|
|
const match = /offset\s+(\d+)/i.exec(message);
|
|
this.offset = match ? Number(match[1]) : undefined;
|
|
}
|
|
}
|
|
|
|
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 imports: string[] = [];
|
|
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
|
|
while (true) {
|
|
while (/\s/u.test(source[lx.pos] ?? "")) lx.pos++;
|
|
if (source.startsWith("//", lx.pos)) {
|
|
while (lx.pos < source.length && source[lx.pos] !== "\n") lx.pos++;
|
|
continue;
|
|
}
|
|
importPattern.lastIndex = lx.pos;
|
|
const statement = importPattern.exec(source);
|
|
if (!statement) break;
|
|
imports.push(statement[0].trim());
|
|
lx.pos = importPattern.lastIndex;
|
|
}
|
|
|
|
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, component, layout, global store, or page store.
|
|
const opener = lx.next();
|
|
if (
|
|
opener.type !== "ident" ||
|
|
!["page", "component", "layout", "global"].includes(opener.value)
|
|
) {
|
|
throw new ParseError(
|
|
`Expected 'page', 'component', 'layout', 'global store', or 'page store' but got '${
|
|
opener.value || opener.type
|
|
}' at offset ${opener.pos}`,
|
|
);
|
|
}
|
|
|
|
let kind: PageAst["kind"];
|
|
let storeKind: StoreKind | undefined;
|
|
let name: string;
|
|
if (opener.value === "global") {
|
|
expectKeyword("store");
|
|
kind = "global-store";
|
|
storeKind = "global";
|
|
name = expect("ident").value;
|
|
} else if (
|
|
opener.value === "page" &&
|
|
lx.peek().type === "ident" &&
|
|
lx.peek().value === "store"
|
|
) {
|
|
lx.next();
|
|
kind = "page-store";
|
|
storeKind = "page";
|
|
name = expect("ident").value;
|
|
} else {
|
|
kind = opener.value as "page" | "component" | "layout";
|
|
name = expect("ident").value;
|
|
}
|
|
expect("lbrace");
|
|
|
|
let layout: string | undefined;
|
|
let layoutIsSymbol = false;
|
|
let runtime: PageAst["runtime"];
|
|
let renderMode: PageAst["renderMode"];
|
|
let hydrate: string | undefined;
|
|
const props: PropDecl[] = [];
|
|
const events: EventDecl[] = [];
|
|
const outputs: OutputDecl[] = [];
|
|
const types: string[] = [];
|
|
const states: StateDecl[] = [];
|
|
const computed: ComputedDecl[] = [];
|
|
const effects: EffectBlock[] = [];
|
|
const loads: LoadBlock[] = [];
|
|
const actions: ActionBlock[] = [];
|
|
const security: Record<string, string> = {};
|
|
const cache: Record<string, string> = {};
|
|
const navigation: Record<string, string> = {};
|
|
const seo: SeoBlock = {};
|
|
const view: ViewNode[] = [];
|
|
const styles: string[] = [];
|
|
const functions: string[] = [];
|
|
const runtimeFunctions: RuntimeFunctionDecl[] = [];
|
|
const dataApis: DataApiBlock[] = [];
|
|
const modeFunctions: ModeFunctionsBlock[] = [];
|
|
const lifecycle: LifecycleBlock = {};
|
|
let storeLifecycle: StoreLifecycleDecl = {};
|
|
let persist: PersistDecl | undefined;
|
|
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");
|
|
const layoutToken = lx.next();
|
|
if (layoutToken.type !== "string" && layoutToken.type !== "ident") {
|
|
throw new ParseError(
|
|
`Expected a layout string or imported symbol at offset ${layoutToken.pos}`,
|
|
);
|
|
}
|
|
layout = layoutToken.value;
|
|
layoutIsSymbol = layoutToken.type === "ident";
|
|
break;
|
|
}
|
|
case "runtime": {
|
|
lx.next();
|
|
expect("eq");
|
|
const value = expect("string").value;
|
|
if (!WRN_RUNTIME_TARGETS.includes(value as (typeof WRN_RUNTIME_TARGETS)[number])) {
|
|
throw new ParseError(
|
|
`Unknown runtime target '${value}' at offset ${kw.pos}`,
|
|
"WRN-RUNTIME-TARGET",
|
|
);
|
|
}
|
|
runtime = value as PageAst["runtime"];
|
|
break;
|
|
}
|
|
case "render": {
|
|
lx.next();
|
|
expect("eq");
|
|
const value = expect("string").value;
|
|
if (!["static", "server", "hybrid", "client", "partial-static"].includes(value))
|
|
throw new ParseError(
|
|
`Unknown render mode '${value}' at offset ${kw.pos}`,
|
|
"WRN-RENDER-MODE",
|
|
);
|
|
renderMode = value as PageAst["renderMode"];
|
|
break;
|
|
}
|
|
case "hydrate": {
|
|
lx.next();
|
|
expect("eq");
|
|
const value = expect("string").value;
|
|
hydrate = value === "never" ? "none" : value;
|
|
break;
|
|
}
|
|
case "props": {
|
|
// props { name: Type = <default>; @event name = function }
|
|
lx.next();
|
|
expect("lbrace");
|
|
while (true) {
|
|
if (lx.startsWithBlockComment()) {
|
|
throw new ParseError(
|
|
"Block comments are not allowed inside props {}; use // line comments instead",
|
|
"WRN-PROPS-BLOCK-COMMENT",
|
|
);
|
|
}
|
|
if (lx.peek().type === "rbrace") break;
|
|
const t = lx.peek();
|
|
if (t.type === "eof") throw new ParseError("Unexpected end of input inside props");
|
|
if (t.type === "at") {
|
|
lx.next();
|
|
const declarationKind = expect("ident");
|
|
if (declarationKind.value !== "event") {
|
|
throw new ParseError(
|
|
`Expected '@event' but got '@${declarationKind.value}' at offset ${declarationKind.pos}`,
|
|
);
|
|
}
|
|
const eventName = expect("ident").value;
|
|
expect("eq");
|
|
const marker = lx.readPropInitializer();
|
|
if (marker !== "function") {
|
|
throw new ParseError(
|
|
`Event '${eventName}' must be declared as '@event ${eventName} = function'`,
|
|
);
|
|
}
|
|
events.push({ name: eventName });
|
|
continue;
|
|
}
|
|
if (t.type !== "ident") {
|
|
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
|
}
|
|
const pName = expect("ident").value;
|
|
const optional = lx.peek().type === "question" ? (lx.next(), true) : false;
|
|
let valueType: string | undefined;
|
|
let hasDefault = false;
|
|
if (lx.peek().type === "colon") {
|
|
lx.next();
|
|
const annotation = lx.readTypeAnnotation();
|
|
valueType = annotation.type;
|
|
hasDefault = annotation.hasDefault;
|
|
} else {
|
|
expect("eq");
|
|
hasDefault = true;
|
|
}
|
|
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
|
|
props.push({
|
|
name: pName,
|
|
valueType,
|
|
required: !hasDefault && !optional,
|
|
default: defaultValue,
|
|
});
|
|
}
|
|
expect("rbrace");
|
|
break;
|
|
}
|
|
case "state": {
|
|
lx.next();
|
|
if (lx.peek().type === "lbrace") {
|
|
const grouped = parseStateDeclarations(lx.readBalancedBraces(), "shared");
|
|
states.push(...grouped);
|
|
break;
|
|
}
|
|
const sName = expect("ident").value;
|
|
let valueType: string | undefined;
|
|
if (lx.peek().type === "colon") {
|
|
lx.next();
|
|
const annotation = lx.readTypeAnnotation();
|
|
valueType = annotation.type;
|
|
if (!annotation.hasDefault)
|
|
throw new ParseError(`State '${sName}' requires an initializer`);
|
|
} else {
|
|
expect("eq");
|
|
}
|
|
states.push({
|
|
name: sName,
|
|
valueType,
|
|
expr: lx.readPropInitializer(),
|
|
runtime: "shared",
|
|
});
|
|
break;
|
|
}
|
|
case "computed": {
|
|
lx.next();
|
|
if (lx.peek().type === "lbrace") {
|
|
computed.push(...parseComputedDeclarations(lx.readBalancedBraces()));
|
|
} else {
|
|
const cName = expect("ident").value;
|
|
let valueType: string | undefined;
|
|
if (lx.peek().type === "colon") {
|
|
lx.next();
|
|
const annotation = lx.readTypeAnnotation();
|
|
valueType = annotation.type;
|
|
if (!annotation.hasDefault)
|
|
throw new ParseError(`Computed '${cName}' requires an expression`);
|
|
} else expect("eq");
|
|
computed.push({ name: cName, valueType, expr: lx.readPropInitializer() });
|
|
}
|
|
break;
|
|
}
|
|
case "outputs": {
|
|
lx.next();
|
|
try {
|
|
outputs.push(...parseOutputs(lx.readBalancedBraces()));
|
|
} catch (error) {
|
|
throw new ParseError(
|
|
error instanceof Error ? error.message : String(error),
|
|
"WRN-OUTPUT-DECLARATION",
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "effect": {
|
|
lx.next();
|
|
effects.push({ body: lx.readBalancedBraces() });
|
|
break;
|
|
}
|
|
case "types": {
|
|
lx.next();
|
|
types.push(lx.readBalancedBraces());
|
|
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 "security": {
|
|
lx.next();
|
|
Object.assign(security, parseSeoBlock(lx.readBalancedBraces()));
|
|
break;
|
|
}
|
|
case "navigation": {
|
|
lx.next();
|
|
Object.assign(navigation, parseSeoBlock(lx.readBalancedBraces()));
|
|
break;
|
|
}
|
|
case "cache": {
|
|
lx.next();
|
|
Object.assign(cache, parseSeoBlock(lx.readBalancedBraces()));
|
|
break;
|
|
}
|
|
case "load": {
|
|
lx.next();
|
|
const first = expect("ident");
|
|
const mode = first.value === "client" ? "client" : "server";
|
|
const name =
|
|
first.value === "server" || first.value === "client"
|
|
? lx.peek().type === "ident"
|
|
? expect("ident").value
|
|
: undefined
|
|
: first.value;
|
|
const dependsOn: string[] = [];
|
|
let deferred = false;
|
|
while (lx.peek().type === "ident") {
|
|
if (lx.peek().value === "defer") {
|
|
lx.next();
|
|
deferred = true;
|
|
continue;
|
|
}
|
|
if (lx.peek().value !== "after") break;
|
|
lx.next();
|
|
dependsOn.push(expect("ident").value);
|
|
while (lx.peek().type === "comma") {
|
|
lx.next();
|
|
dependsOn.push(expect("ident").value);
|
|
}
|
|
}
|
|
loads.push({
|
|
mode,
|
|
name,
|
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
...(deferred ? { deferred: true } : {}),
|
|
body: lx.readBalancedBraces(),
|
|
});
|
|
break;
|
|
}
|
|
case "action": {
|
|
lx.next();
|
|
const actionName = expect("ident").value;
|
|
const args: string[] = [];
|
|
if (lx.peek().type === "lparen") {
|
|
lx.next();
|
|
while (lx.peek().type !== "rparen") {
|
|
args.push(expect("ident").value);
|
|
if (lx.peek().type === "comma") lx.next();
|
|
}
|
|
expect("rparen");
|
|
}
|
|
let schema: string | undefined;
|
|
if (lx.peek().type === "ident" && lx.peek().value === "using") {
|
|
lx.next();
|
|
schema = expect("ident").value;
|
|
}
|
|
actions.push({ name: actionName, args, schema, body: 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":
|
|
case "server": {
|
|
const rawMode = kw.value;
|
|
const mode: DataMode = rawMode === "client" ? "client" : "ssr";
|
|
lx.next();
|
|
if (
|
|
(rawMode === "client" || rawMode === "server") &&
|
|
lx.peek().type === "ident" &&
|
|
lx.peek().value === "state"
|
|
) {
|
|
lx.next();
|
|
if (lx.peek().type !== "lbrace")
|
|
throw new ParseError(`Expected a grouped ${rawMode} state block`);
|
|
states.push(
|
|
...parseStateDeclarations(lx.readBalancedBraces(), rawMode as StateRuntime),
|
|
);
|
|
break;
|
|
}
|
|
if (mode === "client" && lx.peek().type === "eq") {
|
|
lx.next();
|
|
hydrate = expect("string").value;
|
|
break;
|
|
}
|
|
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 "shared": {
|
|
lx.next();
|
|
const member = expect("ident");
|
|
if (member.value !== "state")
|
|
throw new ParseError(`Expected 'state' after shared at offset ${member.pos}`);
|
|
states.push(...parseStateDeclarations(lx.readBalancedBraces(), "shared"));
|
|
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();
|
|
const body = lx.readBalancedBraces();
|
|
if (kind === "global-store" || kind === "page-store") {
|
|
storeLifecycle = parseStoreLifecycle(body);
|
|
const allowedStoreHooks = new Set(["serverInit", "clientInit", "hydrate", "dispose"]);
|
|
const hookLexer = new Lexer(body);
|
|
while (hookLexer.peek().type !== "eof") {
|
|
const token = hookLexer.next();
|
|
if (token.type !== "ident") {
|
|
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
|
|
}
|
|
if (!allowedStoreHooks.has(token.value)) {
|
|
throw new ParseError(`Unknown store lifecycle hook '${token.value}'`);
|
|
}
|
|
hookLexer.readBalancedBraces();
|
|
}
|
|
} else {
|
|
const allowedComponentHooks = new Set([
|
|
"mount",
|
|
"update",
|
|
"unmount",
|
|
"clientInit",
|
|
"dispose",
|
|
]);
|
|
const hookLexer = new Lexer(body);
|
|
while (hookLexer.peek().type !== "eof") {
|
|
const token = hookLexer.next();
|
|
if (token.type !== "ident") {
|
|
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
|
|
}
|
|
if (!allowedComponentHooks.has(token.value)) {
|
|
throw new ParseError(`Unknown lifecycle hook '${token.value}'`);
|
|
}
|
|
const hookBody = hookLexer.readBalancedBraces();
|
|
const hook =
|
|
token.value === "clientInit"
|
|
? "mount"
|
|
: token.value === "dispose"
|
|
? "unmount"
|
|
: (token.value as LifecycleHookName);
|
|
if (lifecycle[hook] !== undefined) {
|
|
throw new ParseError(`Duplicate lifecycle hook '${hook}'`);
|
|
}
|
|
lifecycle[hook] = hookBody;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "watch": {
|
|
lx.next();
|
|
|
|
const stateName = expect("ident").value;
|
|
const body = lx.readBalancedBraces();
|
|
|
|
watches.push({
|
|
state: stateName,
|
|
body,
|
|
});
|
|
|
|
break;
|
|
}
|
|
case "functions": {
|
|
lx.next();
|
|
const body = lx.readBalancedBraces();
|
|
functions.push(body);
|
|
try {
|
|
runtimeFunctions.push(...parseRuntimeFunctions(body));
|
|
} catch (error) {
|
|
throw new ParseError(
|
|
error instanceof Error ? error.message : String(error),
|
|
"WRN-FUNCTION-DECLARATION",
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "persist": {
|
|
lx.next();
|
|
persist = parsePersist(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));
|
|
|
|
if (declaredStates.has("page")) {
|
|
throw new ParseError(
|
|
"State name 'page' collides with the WRN 'page' keyword; choose another state name",
|
|
"WRN-STATE-RESERVED-NAME",
|
|
);
|
|
}
|
|
|
|
for (const watcher of watches) {
|
|
if (!declaredStates.has(watcher.state)) {
|
|
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
|
|
}
|
|
}
|
|
|
|
for (const prop of props) {
|
|
const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
|
|
if (problem) throw new ParseError(problem);
|
|
}
|
|
for (const state of states) {
|
|
const problem = validateTypedInitializer(
|
|
`State '${state.name}'`,
|
|
state.valueType,
|
|
state.expr,
|
|
);
|
|
if (problem) throw new ParseError(problem);
|
|
}
|
|
|
|
const symbols = new Set<string>();
|
|
for (const declaration of [...props, ...states, ...computed]) {
|
|
if (symbols.has(declaration.name)) {
|
|
throw new ParseError(`Duplicate symbol '${declaration.name}'`, "WRN-SYMBOL-DUPLICATE");
|
|
}
|
|
symbols.add(declaration.name);
|
|
}
|
|
|
|
const outputNames = new Set<string>();
|
|
for (const output of outputs) {
|
|
if (outputNames.has(output.name))
|
|
throw new ParseError(`Duplicate output '${output.name}'`, "WRN-OUTPUT-DUPLICATE");
|
|
outputNames.add(output.name);
|
|
}
|
|
const functionKeys = new Set<string>();
|
|
for (const fn of runtimeFunctions) {
|
|
const key = `${fn.runtime}:${fn.name}`;
|
|
if (functionKeys.has(key))
|
|
throw new ParseError(
|
|
`Duplicate ${fn.runtime} function '${fn.name}'`,
|
|
"WRN-FUNCTION-DUPLICATE",
|
|
);
|
|
functionKeys.add(key);
|
|
}
|
|
const namedLoads = new Map(loads.filter((load) => load.name).map((load) => [load.name!, load]));
|
|
for (const load of namedLoads.values()) {
|
|
for (const dependency of load.dependsOn ?? []) {
|
|
const dependencyLoad = namedLoads.get(dependency);
|
|
if (!dependencyLoad)
|
|
throw new ParseError(
|
|
`Load '${load.name}' depends on unknown load '${dependency}'`,
|
|
"WRN-LOAD-DEPENDENCY",
|
|
);
|
|
if (
|
|
load.mode === "server" &&
|
|
!load.deferred &&
|
|
(dependencyLoad.mode !== "server" || dependencyLoad.deferred)
|
|
)
|
|
throw new ParseError(
|
|
`Server load '${load.name}' cannot depend on deferred/client load '${dependency}'`,
|
|
"WRN-LOAD-PHASE",
|
|
);
|
|
}
|
|
}
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
const visitLoad = (name: string): void => {
|
|
if (visiting.has(name))
|
|
throw new ParseError(`Load dependency cycle includes '${name}'`, "WRN-LOAD-CYCLE");
|
|
if (visited.has(name)) return;
|
|
visiting.add(name);
|
|
for (const dependency of namedLoads.get(name)?.dependsOn ?? []) visitLoad(dependency);
|
|
visiting.delete(name);
|
|
visited.add(name);
|
|
};
|
|
for (const name of namedLoads.keys()) visitLoad(name);
|
|
return {
|
|
type: "page",
|
|
imports,
|
|
structuredImports: parseStructuredImports(imports),
|
|
kind,
|
|
storeKind,
|
|
name,
|
|
layout,
|
|
layoutIsSymbol,
|
|
runtime,
|
|
renderMode,
|
|
hydrate,
|
|
cache,
|
|
props,
|
|
events,
|
|
outputs,
|
|
types,
|
|
states,
|
|
computed,
|
|
effects,
|
|
loads,
|
|
actions,
|
|
security,
|
|
navigation,
|
|
seo,
|
|
view,
|
|
styles,
|
|
functions,
|
|
runtimeFunctions,
|
|
dataApis,
|
|
modeFunctions,
|
|
lifecycle,
|
|
storeLifecycle,
|
|
persist,
|
|
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 and quote-aware), braces included. */
|
|
const readInterpolation = (): string => {
|
|
const start = i;
|
|
let depth = 0;
|
|
let quote: string | null = null;
|
|
for (; i < src.length; i++) {
|
|
const char = src[i]!;
|
|
|
|
if (quote) {
|
|
if (char === "\\" && i + 1 < src.length) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char === quote) quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'" || char === "`") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (char === "{") depth++;
|
|
else if (char === "}" && --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();
|
|
const value =
|
|
src[i] === '"' || src[i] === "'"
|
|
? readQuoted()
|
|
: src[i] === "{"
|
|
? readInterpolation()
|
|
: fail(`Expected a quoted value or {...} expression after '${name}='`);
|
|
attrs.push({ name, value, 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+key\s+([\s\S]+?))?\s*\}$/;
|
|
|
|
/** Parse `{#each <list> as <item>[, <index>] [key <expr>]} …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 key = m[4]?.trim();
|
|
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, key, 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 };
|
|
}
|