release: WRNexusJS 0.3.0

This commit is contained in:
2026-07-22 17:29:08 +05:30
parent 13dfa31d19
commit 07d8fb59d6
145 changed files with 9664 additions and 3881 deletions
+132 -15
View File
@@ -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)};`);
+63 -35
View File
@@ -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,
};
}
+2 -816
View File
@@ -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";
+2 -321
View File
@@ -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";
+2 -63
View File
@@ -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";