release: WRNexusJS 0.3.0
This commit is contained in:
@@ -654,6 +654,32 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<strin
|
||||
}`;
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function hydrationId(ast: PageAst): string {
|
||||
const shape = JSON.stringify({
|
||||
kind: ast.kind,
|
||||
name: ast.name,
|
||||
props: ast.props.map((entry) => entry.name),
|
||||
states: ast.states.map((entry) => entry.name),
|
||||
computed: ast.computed.map((entry) => entry.name),
|
||||
view: ast.view,
|
||||
});
|
||||
return `${ast.name}:${stableHash(shape)}`;
|
||||
}
|
||||
|
||||
function hydrationAttribute(ast: PageAst): string {
|
||||
const strategy = ast.hydrate ?? "load";
|
||||
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`;
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
if (ast.kind === "component" || ast.kind === "layout") {
|
||||
return generateComponent(ast);
|
||||
@@ -682,22 +708,46 @@ export function generate(ast: PageAst): string {
|
||||
// --- Page metadata / SEO ---
|
||||
out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`);
|
||||
if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`);
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
|
||||
// --- View -> default page component ---
|
||||
const seedScope = evalStateSeeds(ast.states);
|
||||
for (const entry of ast.computed) {
|
||||
try {
|
||||
seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(
|
||||
seedScope,
|
||||
);
|
||||
} catch {
|
||||
seedScope[entry.name] = undefined;
|
||||
}
|
||||
}
|
||||
const reactiveNames = [
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
];
|
||||
const reactive: PageReactive | null =
|
||||
ast.states.length > 0
|
||||
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
|
||||
: null;
|
||||
reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), scope: seedScope } : null;
|
||||
const loops: string[] = [];
|
||||
let html = ast.view
|
||||
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
|
||||
const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast);
|
||||
const needsClientRuntime =
|
||||
ast.runtime !== "server" &&
|
||||
(ast.states.length > 0 ||
|
||||
ast.computed.length > 0 ||
|
||||
hasClientBehavior(ast.view) ||
|
||||
pageBehavior !== null);
|
||||
|
||||
if (needsClientRuntime) {
|
||||
const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__";
|
||||
html = `<div data-scope="${scopePlaceholder}">${html}</div>`;
|
||||
html = `<div data-scope="${scopePlaceholder}"${behaviorAttribute(pageBehavior)}${hydrationAttribute(ast)}>${html}</div>`;
|
||||
}
|
||||
|
||||
if (styles.length > 0) {
|
||||
@@ -707,6 +757,9 @@ export function generate(ast: PageAst): string {
|
||||
if (csrBindings.length > 0) {
|
||||
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
|
||||
}
|
||||
if (pageBehavior) {
|
||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(pageBehavior, null, 2)};`);
|
||||
}
|
||||
|
||||
// Escape the static HTML for the template literal, then swap loop sentinels for
|
||||
// their real `${…}` code (which must NOT be escaped).
|
||||
@@ -805,6 +858,32 @@ export function generate(ast: PageAst): string {
|
||||
);
|
||||
}
|
||||
|
||||
if (ast.loads.length > 0) {
|
||||
const serverLoads = ast.loads.filter((entry) => entry.mode === "server");
|
||||
const clientLoads = ast.loads.filter((entry) => entry.mode === "client");
|
||||
if (serverLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`,
|
||||
);
|
||||
}
|
||||
if (clientLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusClientLoad(ctx: any) {
|
||||
${clientLoads.map((entry) => entry.body).join("\n")}
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (ast.actions.length > 0) {
|
||||
for (const action of ast.actions) {
|
||||
out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`);
|
||||
}
|
||||
out.push(
|
||||
`export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- API blocks -> method handlers ---
|
||||
if (ast.apis.length > 0) {
|
||||
ast.apis.forEach((api, index) => {
|
||||
@@ -863,6 +942,8 @@ interface CompCtx {
|
||||
|
||||
interface ComponentBehavior {
|
||||
functions: string;
|
||||
computed: Array<{ name: string; expr: string }>;
|
||||
effects: string[];
|
||||
lifecycle: {
|
||||
mount?: string;
|
||||
update?: string;
|
||||
@@ -874,13 +955,16 @@ interface ComponentBehavior {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */
|
||||
export function parseForExpr(value: string): { item: string; index?: string; list: string } | null {
|
||||
const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
value,
|
||||
);
|
||||
/** Parse a `data-for="item in list [key expr]"` / `"item, i in list [key expr]"` directive. */
|
||||
export function parseForExpr(
|
||||
value: string,
|
||||
): { item: string; index?: string; list: string; key?: string } | null {
|
||||
const m =
|
||||
/^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(
|
||||
value,
|
||||
);
|
||||
if (!m) return null;
|
||||
return { item: m[1]!, index: m[2], list: m[3]! };
|
||||
return { item: m[1]!, index: m[2], list: m[3]!.trim(), key: m[4]?.trim() };
|
||||
}
|
||||
|
||||
/** The loop variables a node introduces via `data-for`, if any. */
|
||||
@@ -953,6 +1037,9 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
.join("\n\n"),
|
||||
);
|
||||
|
||||
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
|
||||
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
|
||||
|
||||
const lifecycle = {
|
||||
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
|
||||
|
||||
@@ -966,12 +1053,20 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
body: watch.body.trim(),
|
||||
}));
|
||||
|
||||
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
|
||||
if (
|
||||
!functions &&
|
||||
computed.length === 0 &&
|
||||
effects.length === 0 &&
|
||||
Object.keys(lifecycle).length === 0 &&
|
||||
watches.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
functions,
|
||||
computed,
|
||||
effects,
|
||||
lifecycle,
|
||||
watches,
|
||||
};
|
||||
@@ -1357,12 +1452,16 @@ function generateComponent(ast: PageAst): string {
|
||||
]
|
||||
: ast.props;
|
||||
|
||||
const stateNames = new Set(ast.states.map((s) => s.name));
|
||||
const stateNames = new Set([
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
]);
|
||||
const nameRefs = new Map<string, string>();
|
||||
for (const p of effectiveProps) {
|
||||
nameRefs.set(p.name, safeRef(p.name));
|
||||
}
|
||||
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
|
||||
for (const entry of ast.computed) nameRefs.set(entry.name, safeRef(entry.name));
|
||||
const resolveExpr = (expr: string): string => {
|
||||
let result = expr;
|
||||
for (const [name, ref] of nameRefs) {
|
||||
@@ -1391,7 +1490,12 @@ function generateComponent(ast: PageAst): string {
|
||||
// no JavaScript at all.
|
||||
const behavior = componentBehavior(ast);
|
||||
|
||||
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null;
|
||||
const needsScope =
|
||||
ast.runtime !== "server" &&
|
||||
(ast.states.length > 0 ||
|
||||
ast.computed.length > 0 ||
|
||||
viewHasEvents(ast.view) ||
|
||||
behavior !== null);
|
||||
|
||||
const scopeKeys = [
|
||||
...effectiveProps.map((prop) => prop.name),
|
||||
@@ -1418,9 +1522,16 @@ function generateComponent(ast: PageAst): string {
|
||||
` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`,
|
||||
);
|
||||
}
|
||||
for (const entry of ast.computed) {
|
||||
decls.push(` const ${nameRefs.get(entry.name)} = (${resolveExpr(entry.expr)});`);
|
||||
}
|
||||
|
||||
const returnExpr = needsScope
|
||||
? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`"
|
||||
? "`" +
|
||||
styleTag +
|
||||
`<div data-scope="\${__scope}"${behaviorAttr}${hydrationAttribute(ast)}>` +
|
||||
viewCode +
|
||||
"</div>`"
|
||||
: "`" + styleTag + viewCode + "`";
|
||||
|
||||
const scopeLine =
|
||||
@@ -1437,6 +1548,12 @@ function generateComponent(ast: PageAst): string {
|
||||
} else {
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
}
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
|
||||
if (behavior) {
|
||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
||||
|
||||
@@ -1,74 +1,102 @@
|
||||
/**
|
||||
* @wrnexus/compiler — the `.wrn` language compiler.
|
||||
*
|
||||
* Pipeline: source ──▶ Lexer ──▶ parse() ──▶ AST ──▶ generate() ──▶ TypeScript
|
||||
*
|
||||
* See VISION.md for the language design. The MVP supports `page` with `state`,
|
||||
* `view`, `api`, and `realtime` blocks, lowering to the framework's primitives.
|
||||
* Parsing and language diagnostics are provided by the canonical
|
||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||
*/
|
||||
|
||||
import { parse, ParseError, type PageAst } from "./parser.ts";
|
||||
import {
|
||||
assertValidAst,
|
||||
diagnose,
|
||||
diagnosticFromError,
|
||||
formatDiagnostic,
|
||||
parse,
|
||||
ParseError,
|
||||
type PageAst,
|
||||
type WrnDiagnostic,
|
||||
} from "@wrnexus/syntax";
|
||||
import { generate } from "./codegen.ts";
|
||||
import { generateNative } from "./native-codegen.ts";
|
||||
|
||||
export { parse, ParseError } from "./parser.ts";
|
||||
export {
|
||||
assertValidAst,
|
||||
diagnose,
|
||||
diagnosticFromError,
|
||||
formatDiagnostic,
|
||||
parse,
|
||||
ParseError,
|
||||
} from "@wrnexus/syntax";
|
||||
export { generate } from "./codegen.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "./tokenizer.ts";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "./types.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
export type {
|
||||
PageAst,
|
||||
SeoBlock,
|
||||
ViewNode,
|
||||
Attr,
|
||||
StateDecl,
|
||||
PropDecl,
|
||||
ActionBlock,
|
||||
ApiBlock,
|
||||
Attr,
|
||||
ComputedDecl,
|
||||
DataApiBlock,
|
||||
DataMode,
|
||||
EffectBlock,
|
||||
LoadBlock,
|
||||
ModeFunctionsBlock,
|
||||
PageAst,
|
||||
PropDecl,
|
||||
RealtimeBlock,
|
||||
} from "./parser.ts";
|
||||
SeoBlock,
|
||||
StateDecl,
|
||||
ViewNode,
|
||||
WrnDiagnostic,
|
||||
} from "@wrnexus/syntax";
|
||||
|
||||
export interface CompileResult {
|
||||
code: string;
|
||||
ast: PageAst;
|
||||
/** Backward-compatible plain diagnostic messages. */
|
||||
diagnostics: string[];
|
||||
/** Structured diagnostics for editors, CI, and the DevToolbar. */
|
||||
richDiagnostics: WrnDiagnostic[];
|
||||
}
|
||||
|
||||
/** Compile `.wrn` source into an Expo Router React Native screen. */
|
||||
export function compileNativeWireFile(source: string): string {
|
||||
return generateNative(parse(source));
|
||||
const ast = parse(source);
|
||||
assertValidAst(ast);
|
||||
return generateNative(ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile `.wrn` source into TypeScript source. Throws `ParseError` on invalid
|
||||
* input (the dev loader surfaces this as a readable error page).
|
||||
* Compile `.wrn` source into TypeScript source. Errors include a stable code,
|
||||
* source location, code frame, and actionable hint whenever available.
|
||||
*/
|
||||
export function compileWireFile(source: string, filePath = "<inline .wrn>"): string {
|
||||
let ast;
|
||||
|
||||
try {
|
||||
ast = parse(source);
|
||||
const ast = parse(source);
|
||||
assertValidAst(ast, { file: filePath, accessibility: true });
|
||||
return `// compiled from .wrn\n${generate(ast)}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
throw new Error(`Failed to parse ${filePath}: ${message}`, {
|
||||
const diagnostic = diagnosticFromError(source, error, { file: filePath });
|
||||
throw new Error(`Failed to parse ${filePath}:\n\n${formatDiagnostic(source, diagnostic)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return `// compiled from .wrn\n${generate(ast)}`;
|
||||
}
|
||||
|
||||
/** Richer entry point returning the AST and diagnostics alongside the code. */
|
||||
export function compile(source: string): CompileResult {
|
||||
const diagnostics: string[] = [];
|
||||
try {
|
||||
const ast = parse(source);
|
||||
return { code: `// compiled from .wrn\n${generate(ast)}`, ast, diagnostics };
|
||||
} catch (err) {
|
||||
if (err instanceof ParseError) diagnostics.push(err.message);
|
||||
throw err;
|
||||
/** Richer entry point returning the AST and structured diagnostics. */
|
||||
export function compile(source: string, filePath = "<inline .wrn>"): CompileResult {
|
||||
const richDiagnostics = diagnose(source, { file: filePath, accessibility: true });
|
||||
const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
if (errors.length > 0) {
|
||||
throw new ParseError(
|
||||
errors.map((diagnostic) => diagnostic.message).join("\n"),
|
||||
errors[0]!.code,
|
||||
);
|
||||
}
|
||||
const ast = parse(source);
|
||||
return {
|
||||
code: `// compiled from .wrn\n${generate(ast)}`,
|
||||
ast,
|
||||
diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
|
||||
richDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,816 +1,2 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export interface StateDecl {
|
||||
name: string;
|
||||
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||
valueType?: 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;
|
||||
/** 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 PageAst {
|
||||
type: "page";
|
||||
/** Static ES module imports declared before the WRN root declaration. */
|
||||
imports: string[];
|
||||
/**
|
||||
* `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[];
|
||||
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
||||
types: string[];
|
||||
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 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, 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 types: string[] = [];
|
||||
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: Type = <default> } — omit the default for required props.
|
||||
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;
|
||||
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, default: defaultValue });
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "state": {
|
||||
lx.next();
|
||||
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.readToLineEnd() });
|
||||
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 "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}'`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
kind,
|
||||
name,
|
||||
layout,
|
||||
props,
|
||||
types,
|
||||
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 };
|
||||
}
|
||||
/** @deprecated Import parser APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/parser";
|
||||
|
||||
@@ -1,321 +1,2 @@
|
||||
/**
|
||||
* Lexer for the `.wrn` language.
|
||||
*
|
||||
* `.wrn` mixes a small structural grammar (page/state/view/api/realtime) with
|
||||
* raw JavaScript bodies. A pure token stream can't represent the raw JS, so the
|
||||
* lexer is driven on demand by the parser: it yields structural tokens via
|
||||
* `next()`/`peek()`, and exposes `readBalancedBraces()`, `readPath()` and
|
||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
| "ident"
|
||||
| "string"
|
||||
| "lbrace"
|
||||
| "rbrace"
|
||||
| "lparen"
|
||||
| "rparen"
|
||||
| "at"
|
||||
| "eq"
|
||||
| "colon"
|
||||
| "comma"
|
||||
| "eof";
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||
|
||||
export class Lexer {
|
||||
pos = 0;
|
||||
constructor(public readonly src: string) {}
|
||||
|
||||
/** Skip whitespace and `// line comments`. */
|
||||
private skipTrivia(): void {
|
||||
const { src } = this;
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
if (isWs(c)) {
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && src[this.pos + 1] === "/") {
|
||||
while (this.pos < src.length && src[this.pos] !== "\n") this.pos++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and consume the next structural token. */
|
||||
next(): Token {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
const pos = this.pos;
|
||||
if (pos >= src.length) return { type: "eof", value: "", pos };
|
||||
|
||||
const c = src[pos]!;
|
||||
switch (c) {
|
||||
case "{":
|
||||
this.pos++;
|
||||
return { type: "lbrace", value: c, pos };
|
||||
case "}":
|
||||
this.pos++;
|
||||
return { type: "rbrace", value: c, pos };
|
||||
case "(":
|
||||
this.pos++;
|
||||
return { type: "lparen", value: c, pos };
|
||||
case ")":
|
||||
this.pos++;
|
||||
return { type: "rparen", value: c, pos };
|
||||
case "@":
|
||||
this.pos++;
|
||||
return { type: "at", value: c, pos };
|
||||
case "=":
|
||||
this.pos++;
|
||||
return { type: "eq", value: c, pos };
|
||||
case ":":
|
||||
this.pos++;
|
||||
return { type: "colon", value: c, pos };
|
||||
case ",":
|
||||
this.pos++;
|
||||
return { type: "comma", value: c, pos };
|
||||
case '"':
|
||||
case "'":
|
||||
return this.readString(c, pos);
|
||||
}
|
||||
|
||||
if (isIdentStart(c)) {
|
||||
let v = "";
|
||||
while (this.pos < src.length && isIdentPart(src[this.pos]!)) v += src[this.pos++];
|
||||
return { type: "ident", value: v, pos };
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${c}' at offset ${pos} (line ${this.lineAt(pos)})`);
|
||||
}
|
||||
|
||||
/** Look at the next token without consuming it. */
|
||||
peek(): Token {
|
||||
const save = this.pos;
|
||||
const t = this.next();
|
||||
this.pos = save;
|
||||
return t;
|
||||
}
|
||||
|
||||
private readString(quote: string, pos: number): Token {
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
this.pos++; // opening quote
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos++]!;
|
||||
if (c === "\\") {
|
||||
const n = src[this.pos++]!;
|
||||
v += n === "n" ? "\n" : n === "t" ? "\t" : n;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) return { type: "string", value: v, pos };
|
||||
v += c;
|
||||
}
|
||||
throw new LexError(`Unterminated string at offset ${pos}`);
|
||||
}
|
||||
|
||||
/** Read a route path like `/users/[id]` up to whitespace or `{`. */
|
||||
readPath(): string {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
while (this.pos < src.length && !isWs(src[this.pos]!) && src[this.pos] !== "{") {
|
||||
v += src[this.pos++];
|
||||
}
|
||||
if (!v) throw new LexError(`Expected a path at offset ${this.pos}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a prop default initializer. The initializer may contain nested arrays,
|
||||
* objects, calls, strings, or template literals. At top level it ends at a
|
||||
* newline, the closing brace of the props block, or the next inline prop
|
||||
* declaration (`name = ...` / `name: Type = ...`).
|
||||
*/
|
||||
readPropInitializer(): string {
|
||||
const { src } = this;
|
||||
while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) {
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const start = this.pos;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let angle = 0;
|
||||
let quote: string | null = null;
|
||||
|
||||
const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0;
|
||||
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
|
||||
if (quote) {
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length) {
|
||||
this.pos++;
|
||||
} else if (c === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (atTopLevel()) {
|
||||
if (c === "\n" || c === "\r" || c === "}") break;
|
||||
|
||||
if (c === " " || c === "\t") {
|
||||
let look = this.pos;
|
||||
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
|
||||
const rest = src.slice(look);
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
else if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const value = src.slice(start, this.pos).trim();
|
||||
if (!value) throw new LexError(`Expected a prop initializer at offset ${start}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Read the rest of the current line (used for `state x = <expr>`). */
|
||||
readToLineEnd(): string {
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
while (this.pos < src.length && src[this.pos] !== "\n") v += src[this.pos++];
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a TypeScript-style type annotation after `:`. Reading stops at a
|
||||
* top-level `=` or line ending, while nested object/tuple/generic syntax is
|
||||
* preserved. The optional `=` is consumed for the caller.
|
||||
*/
|
||||
readTypeAnnotation(): { type: string; hasDefault: boolean } {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
if (quote) {
|
||||
value += c;
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length) value += src[this.pos++]!;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
value += c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
|
||||
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||
if (c === "=") {
|
||||
this.pos++;
|
||||
const type = value.trim();
|
||||
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type, hasDefault: true };
|
||||
}
|
||||
if (c === "\n" || c === "\r") break;
|
||||
}
|
||||
value += c;
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const type = value.trim();
|
||||
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type, hasDefault: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `{ ... }` block and return its INNER text (no outer braces), with
|
||||
* brace counting that respects string and template literals so a `}` inside a
|
||||
* string doesn't end the block early.
|
||||
*/
|
||||
readBalancedBraces(): string {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
if (src[this.pos] !== "{") {
|
||||
throw new LexError(`Expected '{' at offset ${this.pos}`);
|
||||
}
|
||||
const start = this.pos + 1;
|
||||
let depth = 0;
|
||||
let i = this.pos;
|
||||
let str: string | null = null;
|
||||
for (; i < src.length; i++) {
|
||||
const c = src[i]!;
|
||||
if (str) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === str) str = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
str = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
this.pos = i + 1;
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||
}
|
||||
|
||||
private lineAt(pos: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < pos && i < this.src.length; i++) {
|
||||
if (this.src[i] === "\n") line++;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/tokenizer";
|
||||
|
||||
@@ -1,63 +1,2 @@
|
||||
/** Utilities shared by typed `.wrn` parsing, validation, and code generation. */
|
||||
|
||||
export type RuntimeType =
|
||||
"string" | "number" | "boolean" | "bigint" | "array" | "object" | "function" | "unknown";
|
||||
|
||||
export function runtimeTypeOf(annotation: string | undefined): RuntimeType {
|
||||
if (!annotation) return "unknown";
|
||||
const type = annotation.trim().replace(/^readonly\s+/, "");
|
||||
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "string";
|
||||
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "number";
|
||||
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "boolean";
|
||||
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "bigint";
|
||||
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type)) return "array";
|
||||
if (/^(?:Record\s*<|object\b|\{)/.test(type)) return "object";
|
||||
if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type)) return "function";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function inferredRuntimeType(expression: string): RuntimeType {
|
||||
const value = expression.trim();
|
||||
if (/^["'`]/.test(value)) return "string";
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return "number";
|
||||
if (/^(?:true|false)$/.test(value)) return "boolean";
|
||||
if (/^-?\d+n$/.test(value)) return "bigint";
|
||||
if (value.startsWith("[")) return "array";
|
||||
if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value)) return "object";
|
||||
if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) {
|
||||
return "function";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function validateTypedInitializer(
|
||||
name: string,
|
||||
annotation: string | undefined,
|
||||
expression: string,
|
||||
): string | null {
|
||||
if (!annotation || expression.trim() === "undefined" || expression.trim() === "null") return null;
|
||||
const expected = runtimeTypeOf(annotation);
|
||||
const actual = inferredRuntimeType(expression);
|
||||
if (expected === "unknown" || actual === "unknown" || expected === actual) return null;
|
||||
return `${name} is declared as ${annotation}, but its initializer is ${actual}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser behavior is evaluated as JavaScript, so erase TypeScript annotations
|
||||
* from ordinary function declarations before serializing it into HTML.
|
||||
* Server output retains the original typed source.
|
||||
*/
|
||||
export function eraseFunctionTypes(source: string): string {
|
||||
return source.replace(
|
||||
/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g,
|
||||
(_whole, open: string, params: string, close: string, _returnType: string, brace: string) => {
|
||||
const plainParams = params
|
||||
.split(",")
|
||||
.map((param) =>
|
||||
param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim(),
|
||||
)
|
||||
.join(", ");
|
||||
return `${open}${plainParams}${close}${brace}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/types";
|
||||
|
||||
Reference in New Issue
Block a user