first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
# @wrnexus/compiler
> Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
## Overview
`@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page.
## Installation
```bash
bun add @wrnexus/compiler
```
> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
## API
All exports come from the package root (`@wrnexus/compiler`).
### `compileWireFile(source: string): string`
Compile `.wrn` source to a TypeScript module string. Throws `ParseError` on invalid input. The output is prefixed with a `// compiled from .wrn` comment.
### `compile(source: string): CompileResult`
Richer entry point that returns the generated code, the AST, and any diagnostics.
```ts
interface CompileResult {
code: string;
ast: PageAst;
diagnostics: string[];
}
```
On a `ParseError` it pushes the message into `diagnostics` and re-throws.
### `parse(source: string): PageAst`
Run the lexer + recursive-descent parser and return the AST. Throws `ParseError` (lexer `LexError`s are caught and rethrown as `ParseError`).
### `generate(ast: PageAst): string`
Lower a `PageAst` to TypeScript. `page` ASTs become a default-export page component (plus `meta`, optional `layout`, `__wrnexusApi`/method handlers, `websocket`, and SSR/CSR data bindings); `component` ASTs become a module exporting `render(props)` and `__wrnexusComponent`.
### `Lexer`
On-demand lexer for `.wrn`. Yields structural tokens and exposes raw-span readers for the parser.
```ts
class Lexer {
pos: number;
constructor(src: string);
next(): Token; // consume next structural token
peek(): Token; // look ahead without consuming
readPath(): string; // route path, e.g. /users/[id]
readToLineEnd(): string; // rest of line (state/prop initializers)
readBalancedBraces(): string; // inner text of a { ... } block, string-aware
}
```
`Token` is `{ type: TokenType; value: string; pos: number }`, where `TokenType` is one of `ident`, `string`, `lbrace`, `rbrace`, `lparen`, `rparen`, `at`, `eq`, `comma`, `eof`.
### Errors
| Class | Thrown by | Meaning |
| ------------ | ------------------------------------------------- | --------------------------------------------------------------- |
| `ParseError` | `parse`, `compile`, `compileWireFile`, `generate` | Invalid `.wrn` grammar or (rewrapped) lex failure. |
| `LexError` | `Lexer` | Unexpected character / unterminated string / unbalanced braces. |
### AST types
Exported type-only symbols describing the parsed tree:
| Type | Description |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. |
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
| `Attr` | `{ name; value; event; boolean? }``event` marks `@event` bindings. |
| `StateDecl` | `{ name; expr }` — a `state x = <expr>` declaration. |
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
| `DataMode` | `"ssr" \| "client"`. |
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
## Usage
Compile a page:
```ts
import { compileWireFile } from "@wrnexus/compiler";
const ts = compileWireFile(`
page Home {
state count = 0
seo { title = "Home" description = "Welcome" }
view {
<button @click="count++">Clicked {count} times</button>
}
}
`);
// ts is a TypeScript module: exports `meta`, and a default page component
// returning an HTML string, wrapped in a data-scope for the reactive runtime.
```
Inspect the AST and diagnostics:
```ts
import { compile, ParseError } from "@wrnexus/compiler";
try {
const { code, ast, diagnostics } = compile(source);
console.log(ast.kind, ast.name, ast.states.length);
} catch (err) {
if (err instanceof ParseError) console.error(err.message);
}
```
Drive the parse/codegen stages directly:
```ts
import { parse, generate } from "@wrnexus/compiler";
const ast = parse(componentSource); // ast.kind === "component"
const module = generate(ast); // exports render(props) + __wrnexusComponent
```
Use the lexer standalone:
```ts
import { Lexer } from "@wrnexus/compiler";
const lx = new Lexer("page Home {");
lx.next(); // { type: "ident", value: "page", pos: 0 }
lx.next(); // { type: "ident", value: "Home", pos: 5 }
lx.next(); // { type: "lbrace", value: "{", pos: 10 }
```
## The `.wrn` language (as parsed)
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
- `props { name = <default> ... }` — component props; each default's type drives coercion.
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
- `view { <html> }` — plain HTML with `{expr}` interpolation, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`.
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
- `functions { <raw js> }` — shared server-side helpers (repeatable).
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
`view` markup is parsed by a lenient dedicated HTML parser (`parseHtmlView`); HTML void elements (`<br>`, `<img>`, …) take no closing tag. Line comments (`//`) are skipped by the lexer.
## Requirements / Notes
- Pure TypeScript with no runtime dependencies; runs under **Bun** as part of the WrNexus toolchain (Node is not supported).
- Generated modules target WrNexus runtime primitives (`data-scope`, `data-text`, `data-on-*`, `data-for`, `data-component`, `__wrnexus*`/`__wire*` helpers) — consume the output within a WrNexus app, e.g. via `@wrnexus/core`'s dev loader.
+62
View File
@@ -0,0 +1,62 @@
# The `.wrn` language vision
`.wrn` is a planned single-file component language for WrNexus. One file can
declare page state, view markup, SSR/client data bindings, styles, and realtime
handlers, and the compiler lowers all of it to the TypeScript primitives the
runtime already understands. Business logic should live in normal `app/api`
route files; `.wrn` data bindings call those routes and render the response.
## Example
```my
page Home {
state count = 0
ssr {
api users GET /api/users {
return users.map((user) => user.name).join(", ")
}
}
client {
api latestUsers GET /api/users/latest {
return users.map((user) => user.name).join(", ")
}
}
view {
<h1>Hello</h1>
<button @click="count++">Count: {count}</button>
<div api="users">Loading users...</div>
<div api="latestUsers">Loading latest users...</div>
}
realtime chat {
on message(data) {
broadcast(data)
}
}
}
```
## Lowering targets
| `.wrn` construct | Compiles to |
| ----------------------------------------- | ------------------------------------------------------- |
| `state x = 0` | a `data-scope` seed hydrated by the reactive runtime |
| `view { ... }` | a page component returning an SSR HTML string |
| `@click="count++"` | a `data-on-click` binding in the reactive runtime |
| `{count}` | a mustache text binding evaluated against `data-scope` |
| `ssr { api users GET /api/users { } }` | server-side API call + HTML replacement before response |
| `client { api users GET /api/users { } }` | opaque CSR binding resolved through `/__wrnexus/csr` |
| `realtime chat { }` | a `websocket` export in the realtime router |
## Pipeline
```
.wrn source ──▶ tokenizer ──▶ parser ──▶ AST ──▶ codegen ──▶ .ts ──▶ Bun runtime
```
The MVP now includes tokenizing, parsing, and code generation for a small real
subset. Future milestones can add richer expressions, typed data contracts,
component composition, and a safer custom expression evaluator.
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.12",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
+862
View File
@@ -0,0 +1,862 @@
/**
* Code generation: lower a `.wrn` AST to TypeScript that targets the framework's
* existing primitives.
*
* state -> a `data-scope` declaration consumed by the runtime
* view -> an HTML string returned by a page component
* @event="..." -> data-on-<event>="..."
* "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime)
* api="<name>" -> SSR/client data binding declared in a mode block
* ssrGet/ssrText -> legacy server-side API fetch + render
* csrGet/csrText -> legacy browser-side API fetch + render
* style -> an inline page stylesheet
* functions -> server-only helpers for API/realtime code
* api M /p {b} -> export const M = async (ctx) => { b }
* realtime {..} -> export const websocket = { evt(ws, ...args) { b } }
*/
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
interface RenderBinding {
method: string;
path: string;
body: string;
helpers: string;
}
interface SsrBinding extends RenderBinding {
marker: string;
}
interface CsrBinding extends RenderBinding {
id: string;
}
interface NamedDataBinding extends RenderBinding {
mode: DataMode;
}
/** Escape a value placed inside a double-quoted HTML attribute. */
function attrEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Make HTML safe to embed inside a JS template literal. */
function templateEscape(html: string): string {
return html.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function styleEscape(css: string): string {
return css.replace(/<\/style/gi, "<\\/style");
}
function attrValue(attrs: Attr[], name: string): string | undefined {
return attrs.find((attr) => !attr.event && attr.name === name)?.value;
}
function renderAttr(attr: Attr): string {
if (attr.event) return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
switch (attr.name) {
case "api":
case "ssrGet":
case "ssrText":
case "csrGet":
case "csrText":
return "";
default:
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
}
}
function eventAttribute(name: string): string {
if (name.startsWith("browser-")) return `data-on-wrnexus-browser-${name.slice(8)}`;
if (name.startsWith("mobile-")) return `data-on-wrnexus-mobile-${name.slice(7)}`;
return `data-on-${name}`;
}
function renderAttrs(attrs: Attr[], csrId?: string): string {
const rendered = attrs.map(renderAttr).join("");
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
}
/**
* Replace i18n text sugar `{t:key}` with a `<span data-t="key">` marker the
* runtime resolves server-side. Other `{expr}` mustaches are left untouched.
*/
function substituteTMarkers(text: string): string {
return text.replace(
/\{t:([^{}]+)\}/g,
(_m, key: string) => `<span data-t="${attrEscape(key.trim())}"></span>`,
);
}
/** Escape a value for safe embedding in HTML text. */
function htmlTextEscape(value: string): string {
return value.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
/** Reactive page context: state names + their initial (SSR) values. */
interface PageReactive {
stateNames: Set<string>;
scope: Record<string, unknown>;
}
/**
* Evaluate a page's `state` seed expressions at compile time to obtain the
* initial SSR values used to bake `data-text` spans. Seeds may reference
* earlier ones; anything that can't be evaluated becomes `undefined`.
*/
function evalStateSeeds(states: { name: string; expr: string }[]): Record<string, unknown> {
const scope: Record<string, unknown> = {};
for (const s of states) {
try {
scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope);
} catch {
scope[s.name] = undefined;
}
}
return scope;
}
/**
* Page text compilation: resolve `{t:key}` i18n markers, then bake state
* interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the
* evaluated initial value — so no-JS clients see real content and the reactive
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
* left as literal client mustaches.
*/
function substituteReactiveText(raw: string, reactive: PageReactive | null): string {
const text = substituteTMarkers(raw);
if (!reactive || reactive.stateNames.size === 0) return text;
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim();
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
let value: unknown;
try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
} catch {
return whole; // can't evaluate → keep as a client-only mustache
}
const baked = htmlTextEscape(value == null ? "" : String(value));
return `<span data-text="${attrEscape(expr)}">${baked}</span>`;
});
}
type EachNode = Extract<ViewNode, { type: "each" }>;
type IfNode = Extract<ViewNode, { type: "if" }>;
/**
* Bake a loop-body text run into template-literal source: static text is escaped
* for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered,
* escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml.
*/
function bakeLoopText(raw: string): string {
let out = "";
let last = 0;
let m: RegExpExecArray | null;
const re = /\{([^{}]+)\}/g;
while ((m = re.exec(raw))) {
out += escLit(raw.slice(last, m.index));
const expr = m[1]!.trim();
if (expr.startsWith("t:")) {
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
} else {
out += "${__wrnexusEscapeHtml(" + expr + ")}";
}
last = m.index + m[0].length;
}
return out + escLit(raw.slice(last));
}
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
function bakeLoopAttr(raw: string): string {
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
let m: RegExpExecArray | null;
const re = /\{([^{}]+)\}/g;
while ((m = re.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
out += "${__wrnexusEscapeHtml(" + m[1]!.trim() + ")}";
last = m.index + m[0].length;
}
return out + escLit(attrEscape(raw.slice(last)));
}
/** Render one loop-body node to template-literal source (nested loops inline). */
function renderLoopBody(node: ViewNode): string {
if (node.type === "text") return bakeLoopText(node.value);
if (node.type === "each") return compileEachExpr(node);
if (node.type === "if") return compileIfExpr(node);
const attrs = node.attrs
.map((a) => {
const name = a.event ? eventAttribute(a.name) : a.name;
if (a.boolean) return escLit(` ${name}`);
return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
})
.join("");
if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
return escLit(`<${node.tag}`) + attrs + escLit(">");
const inner = node.children.map(renderLoopBody).join("");
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
}
/**
* Compile a `{#each list as item}` block to a `${…}` template-literal interpolation
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
*/
function compileEachExpr(node: EachNode): string {
const item = node.item;
const index = node.index ?? "__wi";
const body = node.body.map(renderLoopBody).join("");
const empty = node.empty.map(renderLoopBody).join("");
return (
"${(() => { const __wl = Array.isArray(" +
node.list +
") ? (" +
node.list +
") : []; return __wl.length ? __wl.map((" +
item +
", " +
index +
") => `" +
body +
'`).join("") : `' +
empty +
"`; })()}"
);
}
/**
* Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
* Conditions are JS expressions evaluated in the surrounding server scope.
*/
function compileIfExpr(node: IfNode): string {
let expr = "``"; // no matching branch → empty string
for (let k = node.branches.length - 1; k >= 0; k--) {
const b = node.branches[k]!;
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
}
return "${" + expr + "}";
}
/**
* Collect every server-control expression in a view (recursively): `{#each}` list
* expressions and `{#if}` conditions. Used to wire up raw SSR data consts.
*/
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
for (const node of nodes) {
if (node.type === "each") {
out.push(node.list);
collectControlExprs(node.body, out);
collectControlExprs(node.empty, out);
} else if (node.type === "if") {
for (const b of node.branches) {
if (b.cond) out.push(b.cond);
collectControlExprs(b.body, out);
}
} else if (node.type === "element") {
collectControlExprs(node.children, out);
}
}
return out;
}
function renderNode(
node: ViewNode,
ssrBindings: SsrBinding[],
csrBindings: CsrBinding[],
apiBindings: Map<string, NamedDataBinding>,
loops: string[],
reactive: PageReactive | null = null,
): string {
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
// Server control block (loop / conditional) → a sentinel that survives
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
return `\x00WRNEACH${loops.length - 1}\x00`;
}
const apiName = attrValue(node.attrs, "api");
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
if (apiName && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${apiName}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
const ssrText = attrValue(node.attrs, "ssrText");
const csrGet = attrValue(node.attrs, "csrGet");
const csrText = attrValue(node.attrs, "csrText");
const csrId =
apiBinding?.mode === "client"
? csrMarker(csrBindings, renderBinding(apiBinding))
: csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
// Void elements (<br>, <img>, …) have no closing tag and no children.
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>`;
}
const inner =
apiBinding?.mode === "ssr"
? ssrMarker(ssrBindings, renderBinding(apiBinding))
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
path: apiRoutePath(ssrGet),
body: expressionBody(ssrText),
helpers: "",
})
: node.children
.map((child) =>
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive),
)
.join("");
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>${inner}</${node.tag}>`;
}
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
return marker;
}
function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
const id = String(bindings.length);
bindings.push({ id, ...binding });
return id;
}
function renderBinding(binding: NamedDataBinding): RenderBinding {
return {
method: binding.method,
path: binding.path,
body: binding.body,
helpers: binding.helpers,
};
}
function hasClientBehavior(nodes: ViewNode[]): boolean {
return nodes.some((node) => {
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
if (node.type === "text") return /\{(?!t:)[^{}]+\}/.test(node.value);
// Server control blocks render on the server; they don't add client reactivity.
if (node.type === "each" || node.type === "if") return false;
return (
node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") ||
hasClientBehavior(node.children)
);
});
}
function apiRoutePath(path: string): string {
const trimmed = path.trim();
if (!trimmed.startsWith("/")) {
throw new Error(`.wrn API paths must start with "/": ${path}`);
}
if (trimmed.includes("\0") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
throw new Error(`Unsafe .wrn API path: ${path}`);
}
if (trimmed === "/api" || trimmed.startsWith("/api/")) return trimmed;
return `/api${trimmed}`;
}
function expressionBody(expr: string): string {
return `return (${expr});`;
}
function dataBody(source: string): string {
const trimmed = source.trim();
if (!trimmed) return "return undefined;";
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
}
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
return [
sharedHelpers,
...ast.modeFunctions
.filter((block) => block.mode === mode)
.map((block) => block.body.trim())
.filter(Boolean),
]
.filter(Boolean)
.join("\n\n");
}
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
const bindings = new Map<string, NamedDataBinding>();
for (const block of ast.dataApis) {
if (bindings.has(block.name)) {
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
}
bindings.set(block.name, {
mode: block.mode,
method: block.method,
path: apiRoutePath(block.path),
body: dataBody(block.body),
helpers: modeHelpers(ast, block.mode, sharedHelpers),
});
}
return bindings;
}
function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
}
function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown {
const adapters = {
cookies: ctx.cookies,
session: ctx.session,
localStorage: ctx.localStorage,
};
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
}
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method);
}
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
if (!res.ok) {
throw new Error(".wrn data API request failed with status " + res.status);
}
const type = res.headers.get("content-type") || "";
return type.includes("application/json") ? await res.json() : await res.text();
}
async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<string> {
for (const binding of __wrnexusSsrBindings) {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
}
return html;
}`;
}
export function generate(ast: PageAst): string {
if (ast.kind === "component") return generateComponent(ast);
const out: string[] = [];
const ssrBindings: SsrBinding[] = [];
const csrBindings: CsrBinding[] = [];
const helpers = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const apiBindings = apiBindingMap(ast, helpers);
if (helpers) {
out.push(`// --- .wrn functions ---\n${helpers}`);
}
// --- 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)};`);
// --- View -> default page component ---
const reactive: PageReactive | null =
ast.states.length > 0
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
: 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);
if (needsClientRuntime) {
const scope = ast.states.map((s) => `${s.name}: ${s.expr}`).join(", ");
html = `<div data-scope="${attrEscape(scope)}">${html}</div>`;
}
if (styles.length > 0) {
const css = styles.map(styleEscape).join("\n");
html = `<style data-wrnexus-style="${attrEscape(ast.name)}">\n${css}\n</style>${html}`;
}
if (csrBindings.length > 0) {
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
}
// Escape the static HTML for the template literal, then swap loop sentinels for
// their real `${…}` code (which must NOT be escaped).
let body = templateEscape(html);
loops.forEach((code, idx) => {
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
});
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
const loopConsts: string[] = [];
if (loops.length > 0) {
const lists = collectControlExprs(ast.view);
for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr") continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
loopConsts.push(
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
);
}
}
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0;
if (needsSsrRuntime) {
out.push(ssrRuntimeSource());
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(
`export default async function ${ast.name}(ctx: any) {\n${decls} const html = \`${body}\`;\n return await __wrnexusRenderSsrBindings(html, ctx);\n}`,
);
} else {
out.push(`export default function ${ast.name}() {\n return \`${body}\`;\n}`);
}
// --- API blocks -> method handlers ---
if (ast.apis.length > 0) {
ast.apis.forEach((api, index) => {
const name = `__wrnexusApi_${api.method}_${index}`;
out.push(`// ${api.method} ${apiRoutePath(api.path)}
const ${name} = async (ctx: any) => {${api.body}};`);
});
const entries = ast.apis.map(
(api, index) =>
` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`,
);
out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`);
const exported = new Set<string>();
ast.apis.forEach((api, index) => {
if (exported.has(api.method)) return;
exported.add(api.method);
out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`);
});
}
// --- Realtime blocks -> a websocket export ---
if (ast.realtimes.length > 0) {
const handlers = ast.realtimes.flatMap((rt) =>
rt.handlers.map((h) => {
const params = ["ws", ...h.args].join(", ");
return ` ${h.event}(${params}: any) {${h.body}},`;
}),
);
out.push(`export const websocket = {\n${handlers.join("\n")}\n};`);
}
return out.join("\n\n") + "\n";
}
/**
* Lower a `component` AST to a module exporting `render(props)`.
*
* A component is server-rendered on demand at each `data-component` mount and
* hydrated on the browser by the generic reactive runtime — it ships no JS of
* its own. Declared props are coerced to the type of their default value, then
* seeded (with any `state`) into the `data-scope` the reactive runtime reads.
*/
interface CompCtx {
/** State names — text referencing any of them stays a reactive client mustache. */
stateNames: Set<string>;
/** Rewrite reserved-word prop/state identifiers to their safe const names. */
resolveExpr: (expr: string) => string;
/** `data-for` loop variables in scope — their mustaches stay literal for the
* client's list renderer (never baked server-side, since they have no value). */
loopVars?: Set<string>;
}
/** 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,
);
if (!m) return null;
return { item: m[1]!, index: m[2], list: m[3]! };
}
/** The loop variables a node introduces via `data-for`, if any. */
function loopVarsOf(node: ViewNode): string[] {
if (node.type !== "element") return [];
const attr = node.attrs.find((a) => !a.event && a.name === "data-for");
if (!attr) return [];
const parsed = parseForExpr(attr.value);
return parsed ? [parsed.item, ...(parsed.index ? [parsed.index] : [])] : [];
}
/** JS reserved words that cannot be used as a plain `const` name. */
const JS_RESERVED = new Set([
"class",
"for",
"default",
"function",
"return",
"if",
"else",
"new",
"delete",
"typeof",
"in",
"instanceof",
"void",
"do",
"while",
"switch",
"case",
"break",
"continue",
"this",
"super",
"import",
"export",
"extends",
"var",
"let",
"const",
"null",
"true",
"false",
"try",
"catch",
"finally",
"throw",
"yield",
"await",
"enum",
"with",
"debugger",
]);
/** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */
function safeRef(name: string): string {
return JS_RESERVED.has(name) ? `__p_${name}` : name;
}
/** Escape a literal segment so it is safe inside a JS template literal. */
function escLit(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
const INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
for (const name of stateNames) {
if (new RegExp(`\\b${name}\\b`).test(expr)) return true;
}
return false;
}
function viewHasEvents(nodes: ViewNode[]): boolean {
return nodes.some(
(n) => n.type === "element" && (n.attrs.some((a) => a.event) || viewHasEvents(n.children)),
);
}
/**
* Compile a text node. Interpolations that reference state stay as client
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
* props/constants are baked server-side (`${__wireHtml(expr)}`), so static
* components render correct HTML with zero JavaScript.
*/
function compileText(raw: string, ctx: CompCtx): string {
let out = "";
let last = 0;
let m: RegExpExecArray | null;
INTERP_RE.lastIndex = 0;
while ((m = INTERP_RE.exec(raw))) {
out += escLit(raw.slice(last, m.index));
const expr = m[1]!.trim();
if (expr.startsWith("t:")) {
// i18n sugar: {t:key} → a marker resolved server-side by translateHtml.
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
// Loop variable (from data-for): leave a literal client mustache — the
// list renderer fills it per item; it has no server-side value.
out += escLit(`{${expr}}`);
} else if (exprRefsState(expr, ctx.stateNames)) {
// State interpolation: bake the initial value AND keep it reactive via a
// data-text span, so no-JS clients see the real value and hydration
// updates it in place. `count` → `<span data-text="count">0</span>`.
out +=
escLit(`<span data-text="${attrEscape(expr)}">`) +
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
escLit(`</span>`);
} else {
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
}
last = m.index + m[0].length;
}
return out + escLit(raw.slice(last));
}
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
function compileAttrValue(raw: string, ctx: CompCtx): string {
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
let m: RegExpExecArray | null;
INTERP_RE.lastIndex = 0;
while ((m = INTERP_RE.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
const expr = m[1]!.trim();
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
} else {
out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`;
}
last = m.index + m[0].length;
}
return out + escLit(attrEscape(raw.slice(last)));
}
/** Render a component view node into template-literal-ready source. */
function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (node.type === "text") return compileText(node.value, ctx);
if (node.type === "each" || node.type === "if") {
throw new Error(
"Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).",
);
}
const attrs = node.attrs
.map((a) =>
a.event
? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`
: a.boolean
? ` ${a.name}`
: ` ${a.name}="${compileAttrValue(a.value, ctx)}"`,
)
.join("");
// A `data-for` element introduces loop variables for its subtree.
const loops = loopVarsOf(node);
const childCtx =
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
const inner = node.children.map((c) => renderComponentNode(c, childCtx)).join("");
return `<${node.tag}${attrs}>${inner}</${node.tag}>`;
}
function generateComponent(ast: PageAst): string {
const out: string[] = [];
const stateNames = new Set(ast.states.map((s) => s.name));
const nameRefs = new Map<string, string>();
for (const p of ast.props) nameRefs.set(p.name, safeRef(p.name));
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
const resolveExpr = (expr: string): string => {
let result = expr;
for (const [name, ref] of nameRefs) {
if (name !== ref) result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref);
}
return result;
};
const ctx: CompCtx = { stateNames, resolveExpr };
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
const styleTag =
styles.length > 0
? escLit(
`<style data-wrnexus-style="${attrEscape(ast.name)}">\n${styles.map(styleEscape).join("\n")}\n</style>`,
)
: "";
// A component needs a reactive scope only when it has state or event handlers.
// Prop-driven text/attributes are baked server-side, so static components ship
// no JavaScript at all.
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
const scopeKeys = [...ast.props.map((p) => p.name), ...ast.states.map((s) => s.name)];
const decls: string[] = [];
for (const prop of ast.props) {
decls.push(
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
);
}
for (const state of ast.states) {
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
}
const returnExpr = needsScope
? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`"
: "`" + styleTag + viewCode + "`";
const scopeLine =
needsScope && scopeKeys.length > 0
? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });\n`
: needsScope
? ` const __scope = "";\n`
: "";
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
if (typeof def === "boolean") return v === true || v === "" || v === "true";
return String(v);
}
function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
);
}`);
if (needsScope) {
out.push(`function __wrnexusScopeDecl(obj: Record<string, any>): string {
const lit = (v: any) =>
typeof v === "number" || typeof v === "boolean"
? String(v)
: "'" + String(v).replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'").replace(/\\n/g, "\\\\n") + "'";
return Object.keys(obj)
.map((k) => k + ": " + lit(obj[k]))
.join(", ")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}`);
}
out.push(
`export function render(props: Record<string, any> = {}): string {\n` +
` const __p = props || {};\n` +
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
scopeLine +
` return ${returnExpr};\n` +
`}`,
);
return out.join("\n\n") + "\n";
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @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.
*/
import { parse, ParseError, type PageAst } from "./parser.ts";
import { generate } from "./codegen.ts";
import { generateNative } from "./native-codegen.ts";
export { parse, ParseError } from "./parser.ts";
export { generate } from "./codegen.ts";
export { generateNative, NativeCompileError } from "./native-codegen.ts";
export { Lexer, LexError } from "./tokenizer.ts";
export type {
PageAst,
SeoBlock,
ViewNode,
Attr,
StateDecl,
ApiBlock,
DataApiBlock,
DataMode,
ModeFunctionsBlock,
RealtimeBlock,
} from "./parser.ts";
export interface CompileResult {
code: string;
ast: PageAst;
diagnostics: string[];
}
/** Compile `.wrn` source into an Expo Router React Native screen. */
export function compileNativeWireFile(source: string): string {
return generateNative(parse(source));
}
/**
* Compile `.wrn` source into TypeScript source. Throws `ParseError` on invalid
* input (the dev loader surfaces this as a readable error page).
*/
export function compileWireFile(source: string): string {
const ast = parse(source);
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;
}
}
+233
View File
@@ -0,0 +1,233 @@
import type { Attr, PageAst, ViewNode } from "./parser.ts";
export class NativeCompileError extends Error {
constructor(message: string) {
super(message);
this.name = "NativeCompileError";
}
}
const tagMap: Record<string, string> = {
div: "View",
main: "View",
section: "View",
article: "View",
nav: "View",
header: "View",
footer: "View",
aside: "View",
form: "View",
ul: "View",
ol: "View",
li: "View",
p: "Text",
span: "Text",
strong: "Text",
em: "Text",
small: "Text",
label: "Text",
h1: "Text",
h2: "Text",
h3: "Text",
h4: "Text",
h5: "Text",
h6: "Text",
button: "Pressable",
a: "Pressable",
input: "TextInput",
textarea: "TextInput",
img: "Image",
view: "View",
text: "Text",
pressable: "Pressable",
textinput: "TextInput",
image: "Image",
scrollview: "ScrollView",
safeareaview: "SafeAreaView",
flatlist: "FlatList",
activityindicator: "ActivityIndicator",
};
const attrMap: Record<string, string> = {
class: "style",
className: "style",
src: "source",
alt: "accessibilityLabel",
placeholder: "placeholder",
disabled: "disabled",
value: "value",
href: "__href",
"aria-label": "accessibilityLabel",
};
function expression(value: string): string | null {
const exact = /^\{([\s\S]+)\}$/.exec(value.trim());
return exact?.[1]?.trim() ?? null;
}
function textJsx(value: string): string {
const pieces: string[] = [];
let last = 0;
for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
if (match.index! > last) pieces.push(value.slice(last, match.index));
const expr = match[1]!.trim();
pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`);
last = match.index! + match[0].length;
}
pieces.push(value.slice(last));
return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "&lt;" : "&gt;"));
}
function eventBody(value: string, states: Set<string>): string {
let body = expression(value) ?? value;
for (const state of states) {
const cap = state[0]!.toUpperCase() + state.slice(1);
body = body
.replace(new RegExp(`\\b${state}\\+\\+`, "g"), `set${cap}(value => value + 1)`)
.replace(new RegExp(`\\b${state}--`, "g"), `set${cap}(value => value - 1)`)
.replace(new RegExp(`\\b${state}\\s*=\\s*([^;]+)`, "g"), `set${cap}($1)`);
}
return `() => { ${body} }`;
}
function renderAttrs(attrs: Attr[], states: Set<string>): string {
return attrs
.map((attr) => {
if (attr.event) {
if (attr.name.startsWith("browser-")) return "";
const eventName = attr.name.startsWith("mobile-") ? attr.name.slice(7) : attr.name;
const event =
eventName === "click" || eventName === "press"
? "onPress"
: eventName === "input" || eventName === "change"
? "onChangeText"
: `on${eventName[0]!.toUpperCase()}${eventName.slice(1)}`;
return ` ${event}={${eventBody(attr.value, states)}}`;
}
if (attr.name === "data-native-browser" || attr.name.startsWith("data-native-on-browser-"))
return "";
if (
attr.name === "data-native-options" ||
attr.name === "data-native-only" ||
attr.name === "data-native-requires" ||
attr.name === "data-native-unsupported"
)
return "";
if (attr.name === "data-native-mobile") {
throw new NativeCompileError(
`Declarative native capability "${attr.value}" currently targets browser/Capacitor pages. In Expo output, call the installed Expo package from an @mobile-event handler.`,
);
}
const name = attrMap[attr.name] ?? attr.name;
if (name === "__href") return ` onPress={() => router.push(${JSON.stringify(attr.value)})}`;
if (name === "source") {
const expr = expression(attr.value);
return ` source={${expr ? `{ uri: ${expr} }` : `{ uri: ${JSON.stringify(attr.value)} }`}}`;
}
if (name === "style" && attr.name !== "style") {
return ` style={[${attr.value
.split(/\s+/)
.filter(Boolean)
.map((value) => `styles[${JSON.stringify(value)}]`)
.join(", ")} ]}`;
}
if (name === "style") {
const inlineExpression = expression(attr.value);
if (inlineExpression) return ` style={${inlineExpression}}`;
throw new NativeCompileError(
'Inline CSS strings are not portable to native; use class="name" and a page style block',
);
}
if (attr.boolean) return ` ${name}`;
const expr = expression(attr.value);
return expr ? ` ${name}={${expr}}` : ` ${name}=${JSON.stringify(attr.value)}`;
})
.join("");
}
function renderNode(node: ViewNode, states: Set<string>, key?: string): string {
if (node.type === "text") return textJsx(node.value);
if (node.type === "each") {
const params = node.index ? `${node.item}, ${node.index}` : `${node.item}, __index`;
const body = node.body
.map((child, index) =>
renderNode(child, states, index === 0 ? (node.index ?? "__index") : undefined),
)
.join("");
const empty = node.empty.map((child) => renderNode(child, states)).join("");
return `{(${node.list})?.length ? (${node.list}).map((${params}) => <>${body}</>) : <>${empty}</>}`;
}
if (node.type === "if") {
const result = node.branches.reduceRight(
(fallback, branch) =>
branch.cond === null
? `<>${branch.body.map((child) => renderNode(child, states)).join("")}</>`
: `(${branch.cond}) ? <>${branch.body.map((child) => renderNode(child, states)).join("")}</> : ${fallback}`,
"null",
);
return `{${result}}`;
}
const nativeOnly = node.attrs.find(
(attr) => !attr.event && attr.name === "data-native-only",
)?.value;
if (nativeOnly === "browser" || nativeOnly === "web") return "";
const nativeTag =
tagMap[node.tag.toLowerCase()] ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined);
if (!nativeTag)
throw new NativeCompileError(`HTML element <${node.tag}> has no native equivalent`);
const attrs = renderAttrs(node.attrs, states) + (key ? ` key={${key}}` : "");
if (nativeTag === "TextInput" || nativeTag === "Image" || nativeTag === "ActivityIndicator")
return `<${nativeTag}${attrs} />`;
const children = node.children
.map((child) => {
if (child.type !== "text") return renderNode(child, states);
if (!child.value.trim()) return "";
const text = textJsx(child.value);
return nativeTag === "Text" ? text : `<Text>${text}</Text>`;
})
.join("");
return `<${nativeTag}${attrs}>${children}</${nativeTag}>`;
}
function nativeStyles(blocks: string[]): string {
const entries: string[] = [];
for (const block of blocks) {
for (const match of block.matchAll(/\.([A-Za-z_][\w-]*)\s*\{([^}]*)\}/g)) {
const props: string[] = [];
for (const declaration of match[2]!.split(";")) {
const colon = declaration.indexOf(":");
if (colon < 0) continue;
const name = declaration
.slice(0, colon)
.trim()
.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
let value: string | number = declaration.slice(colon + 1).trim();
if (/^-?\d+(?:\.\d+)?px$/.test(value)) value = Number(value.slice(0, -2));
props.push(
`${JSON.stringify(name)}: ${typeof value === "number" ? value : JSON.stringify(value)}`,
);
}
entries.push(`${JSON.stringify(match[1])}: { ${props.join(", ")} }`);
}
}
return `const styles = StyleSheet.create({ ${entries.join(",\n")} });`;
}
/** Compile a parsed `.wrn` page to an Expo Router React Native screen. */
export function generateNative(ast: PageAst): string {
if (ast.kind !== "page")
throw new NativeCompileError("Native route compilation currently accepts page files only");
if (ast.dataApis.length)
throw new NativeCompileError(
"Data API blocks are not yet portable to native screens; fetch through the generated native backend helper",
);
const states = new Set(ast.states.map((state) => state.name));
const hooks = ast.states
.map((state) => {
const cap = state.name[0]!.toUpperCase() + state.name.slice(1);
return ` const [${state.name}, set${cap}] = useState(${state.expr});`;
})
.join("\n");
const body = ast.view.map((node) => renderNode(node, states)).join("");
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
}
+622
View File
@@ -0,0 +1,622 @@
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
*
* Grammar (subset of the vision, but real):
*
* page <Name> {
* state <ident> = <expr> // zero or more
* view { <html> } // plain HTML (see parseHtmlView)
* seo { title = "Home" description = "..." }
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
* client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
* style { <raw css> } // zero or more, inlined with the page
* functions { <raw js> } // zero or more, shared helpers
* api <METHOD> <path> { <raw js> } // zero or more
* realtime <name> { on <evt>(<args>) { <raw js> } * } // zero or more
* }
*
* The `view` block is written as ordinary HTML — nothing new to learn. Text may
* contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and
* `@event="..."` declares a client event binding. See `parseHtmlView`.
*/
import { Lexer, LexError, type Token } from "./tokenizer.ts";
export interface StateDecl {
name: string;
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
expr: string;
}
export interface Attr {
name: string;
value: string;
/** True for `@event` bindings (vs. plain HTML attributes). */
event: boolean;
/** True for a valueless boolean attribute, e.g. `<button disabled>`. */
boolean?: boolean;
}
/**
* HTML void elements: they have no children and no closing tag.
* @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
*/
export const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
export type ViewNode =
| { type: "text"; value: string }
| { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
/**
* A server-side loop: `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`.
* `list` is a JS expression (evaluated on the server, may reference an `ssr` data
* binding). The `body` is rendered once per item with `{item.field}` interpolation;
* `empty` renders when the list is empty. See codegen `compileEach`.
*/
| {
type: "each";
list: string;
item: string;
index?: string;
body: ViewNode[];
empty: ViewNode[];
}
/**
* A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
* Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
* (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
*/
| { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };
export interface ApiBlock {
method: string;
path: string;
body: string;
}
export type SeoBlock = Record<string, string>;
export type DataMode = "ssr" | "client";
export interface DataApiBlock {
mode: DataMode;
name: string;
method: string;
path: string;
body: string;
}
export interface ModeFunctionsBlock {
mode: DataMode;
body: string;
}
export interface RealtimeHandler {
event: string;
args: string[];
body: string;
}
export interface RealtimeBlock {
name: string;
handlers: RealtimeHandler[];
}
export interface PropDecl {
name: string;
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
default: string;
}
export interface PageAst {
type: "page";
/** `page` (a route) or `component` (a reusable, prop-driven fragment). */
kind: "page" | "component";
name: string;
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
layout?: string;
/** Declared component props (empty for pages). */
props: PropDecl[];
states: StateDecl[];
seo: SeoBlock;
view: ViewNode[];
styles: string[];
functions: string[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
apis: ApiBlock[];
realtimes: RealtimeBlock[];
}
export class ParseError extends Error {}
function parseSeoBlock(body: string): SeoBlock {
const out: SeoBlock = {};
const pair =
/([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g;
for (const match of body.matchAll(pair)) {
const key = match[1]!;
const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
out[key] = unescapeSeoValue(rawValue.trim());
}
return out;
}
function unescapeSeoValue(value: string): string {
return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
if (ch === "n") return "\n";
if (ch === "r") return "\r";
if (ch === "t") return "\t";
return ch;
});
}
export function parse(source: string): PageAst {
const lx = new Lexer(source);
const expect = (type: Token["type"]): Token => {
const t = lx.next();
if (t.type !== type) {
throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`);
}
return t;
};
const expectKeyword = (kw: string): void => {
const t = lx.next();
if (t.type !== "ident" || t.value !== kw) {
throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`);
}
};
try {
// A file is either a `page` (a route) or a `component` (a reusable fragment).
const opener = lx.next();
if (opener.type !== "ident" || (opener.value !== "page" && opener.value !== "component")) {
throw new ParseError(
`Expected 'page' or 'component' but got '${opener.value || opener.type}' at offset ${opener.pos}`,
);
}
const kind: "page" | "component" = opener.value === "component" ? "component" : "page";
const name = expect("ident").value;
expect("lbrace");
let layout: string | undefined;
const props: PropDecl[] = [];
const states: StateDecl[] = [];
const seo: SeoBlock = {};
const view: ViewNode[] = [];
const styles: string[] = [];
const functions: string[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const apis: ApiBlock[] = [];
const realtimes: RealtimeBlock[] = [];
while (lx.peek().type !== "rbrace") {
const kw = lx.peek();
if (kw.type === "eof") throw new ParseError(`Unexpected end of input inside ${kind}`);
if (kw.type !== "ident") {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
expect("eq");
layout = expect("string").value;
break;
}
case "props": {
// props { name = <default> ... } — one declaration per line.
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const t = lx.peek();
if (t.type === "eof") throw new ParseError("Unexpected end of input inside props");
if (t.type !== "ident") {
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
}
const pName = expect("ident").value;
expect("eq");
props.push({ name: pName, default: lx.readToLineEnd() });
}
expect("rbrace");
break;
}
case "state": {
lx.next();
const sName = expect("ident").value;
expect("eq");
states.push({ name: sName, expr: lx.readToLineEnd() });
break;
}
case "view": {
lx.next();
expect("lbrace");
// The view body is plain HTML. Parse it straight off the source
// (the token lexer isn't used for markup), then resume after the
// block's closing `}`.
const { nodes, endPos } = parseHtmlView(lx.src, lx.pos);
view.push(...nodes);
lx.pos = endPos;
expect("rbrace");
break;
}
case "seo": {
lx.next();
Object.assign(seo, parseSeoBlock(lx.readBalancedBraces()));
break;
}
case "api": {
lx.next();
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
apis.push({ method, path, body });
break;
}
case "ssr":
case "client": {
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const member = lx.peek();
if (member.type === "eof") {
throw new ParseError(`Unexpected end of input inside ${mode} block`);
}
if (member.type !== "ident") {
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
}
switch (member.value) {
case "api": {
lx.next();
const name = expect("ident").value;
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
dataApis.push({ mode, name, method, path, body });
break;
}
case "functions": {
lx.next();
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
break;
}
default:
throw new ParseError(
`Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
);
}
}
expect("rbrace");
break;
}
case "realtime": {
lx.next();
const rName = expect("ident").value;
expect("lbrace");
const handlers: RealtimeHandler[] = [];
while (lx.peek().type !== "rbrace") {
expectKeyword("on");
const event = expect("ident").value;
expect("lparen");
const args: string[] = [];
while (lx.peek().type !== "rparen") {
args.push(expect("ident").value);
if (lx.peek().type === "comma") lx.next();
}
expect("rparen");
handlers.push({ event, args, body: lx.readBalancedBraces() });
}
expect("rbrace");
realtimes.push({ name: rName, handlers });
break;
}
case "style": {
lx.next();
styles.push(lx.readBalancedBraces());
break;
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
break;
}
default:
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
}
}
expect("rbrace");
return {
type: "page",
kind,
name,
layout,
props,
states,
seo,
view,
styles,
functions,
dataApis,
modeFunctions,
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 isNamePart = (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 readName = (): string => {
if (i >= src.length || !isNameStart(src[i]!)) return fail("Expected a tag or attribute name");
const start = i++;
while (i < src.length && isNamePart(src[i]!)) i++;
return src.slice(start, i);
};
const parseTag = (): ViewNode => {
i++; // consume '<'
const tag = readName();
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 = readName();
skipWs();
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: true });
continue;
}
const name = readName();
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 = readName();
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 };
}
+184
View File
@@ -0,0 +1,184 @@
/**
* 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" | "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: "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 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 `{ ... }` 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;
}
}
+136
View File
@@ -0,0 +1,136 @@
import { test, expect } from "bun:test";
import { writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { parse } from "../src/index.ts";
import { compileWireFile } from "../src/index.ts";
let seq = 0;
/** Compile a `.wrn` source and import the resulting module. */
async function compileAndImport(src: string): Promise<Record<string, unknown>> {
const dir = join(tmpdir(), "wire-compiler-test");
mkdirSync(dir, { recursive: true });
const file = join(dir, `m${seq++}.ts`);
writeFileSync(file, compileWireFile(src));
return import(pathToFileURL(file).href);
}
test("parses a page with a layout member", () => {
const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`);
expect(ast.kind).toBe("page");
expect(ast.name).toBe("Home");
expect(ast.layout).toBe("public");
});
test("parses a component with props and state", () => {
const ast = parse(
`component C {\n props {\n n = 0\n }\n state count = n\n view { <b>{count}</b> }\n}`,
);
expect(ast.kind).toBe("component");
expect(ast.props.map((p) => p.name)).toEqual(["n"]);
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
});
test("HTML view: void elements, boolean attrs, comments, lone <", () => {
const ast = parse(
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
);
const html = compileWireFile(
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
);
expect(html).toContain("<input");
expect(html).toContain(" disabled");
expect(html).toContain("<br>");
expect(html).not.toContain("comment");
expect(html).toContain("a < b");
void ast;
});
test("stateless component bakes props into server HTML (zero JS)", async () => {
const mod = await compileAndImport(
`component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`,
);
const render = mod.render as (p: Record<string, string>) => string;
const out = render({ label: "Save <b>", variant: "primary", class: "mt-2" });
expect(out).toContain('class="wire-btn wire-btn--primary mt-2"');
expect(out).toContain("Save &lt;b&gt;"); // html-escaped
expect(out).not.toContain("data-scope"); // no reactivity → no scope
});
test("stateful component: state text baked into a reactive data-text span, prop text baked", async () => {
const mod = await compileAndImport(
`component Counter {\n props {\n start = 0\n label = "Count"\n }\n state count = start\n view { <button @click="count++">{label}: {count}</button> }\n}`,
);
const render = mod.render as (p: Record<string, string>) => string;
const out = render({ start: "10", label: "Score" });
expect(out).toContain("data-scope=\"start: 10, label: 'Score', count: 10\"");
expect(out).toContain('data-on-click="count++"');
// label baked as static text; count baked as its initial value AND kept live.
expect(out).toContain('Score: <span data-text="count">10</span>');
});
test("prop type coercion follows the default value's type", async () => {
const mod = await compileAndImport(
`component X {\n props {\n n = 0\n s = "x"\n b = false\n }\n view { <i>{n}{s}{b}</i> }\n}`,
);
// needsScope=false → seeds nothing; verify via a stateful variant instead:
const mod2 = await compileAndImport(
`component Y {\n props {\n n = 0\n }\n state v = n\n view { <i @click="v++">{v}</i> }\n}`,
);
const out = (mod2.render as (p: Record<string, string>) => string)({ n: "42" });
expect(out).toContain("v: 42"); // "42" coerced to number 42 (not '42')
void mod;
});
test("platform events compile through the native runtime bridge", () => {
const out = compileWireFile(`page Platform {
state count = 0
view {
<button @browser-click="count++" @mobile-click="count = count + 2">Run</button>
}
}`);
expect(out).toContain('data-on-wrnexus-browser-click="count++"');
expect(out).toContain('data-on-wrnexus-mobile-click="count = count + 2"');
});
test("{t:key} compiles to a data-t marker (both pages and components)", () => {
const page = compileWireFile(`page P {\n view { <h1>{t:home.title}</h1> }\n}`);
expect(page).toContain('<span data-t="home.title"></span>');
const comp = compileWireFile(`component C {\n view { <h1>{t:x}</h1> }\n}`);
expect(comp).toContain('<span data-t="x"></span>');
});
test("{t:} does NOT wrap a page in a data-scope (regression)", () => {
// Only state / events force a reactive scope, not i18n markers.
const page = compileWireFile(`page P {\n view { <h1>{t:a}</h1> <p>{t:b}</p> }\n}`);
expect(page).not.toContain("data-scope");
});
test("page state text bakes its initial value into a reactive data-text span", () => {
const page = compileWireFile(
`page Reactive {\n state count = 3\n view { <p>Count is {count}, doubled {count * 2}</p> }\n}`,
);
expect(page).toContain('<span data-text="count">3</span>'); // no-JS sees "3"
expect(page).toContain('<span data-text="count * 2">6</span>'); // expression evaluated
expect(page).toContain("data-scope"); // state still forces a reactive scope
});
test("data-for: loop-variable mustaches stay literal (not baked server-side)", () => {
const comp = compileWireFile(
`component TodoList {\n state todos = []\n view { <ul><li data-for="t in todos">{t.text}</li></ul> }\n}`,
);
// The <li> template keeps `{t.text}` for the client list renderer, and the
// loop variable is never baked (which would be a server-side ReferenceError).
expect(comp).toContain('data-for="t in todos"');
expect(comp).toContain("{t.text}");
expect(comp).not.toContain("__wireHtml(t.text)");
});
test("named slots pass through to the component output", () => {
const out = compileWireFile(
`component Card {\n view { <div><slot name="header"></slot><slot></slot></div> }\n}`,
);
expect(out).toContain('<slot name="header">');
expect(out).toContain("<slot></slot>");
});
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test";
import { NativeCompileError, compileNativeWireFile } from "../src/index.ts";
test("compiles portable wrn markup to React Native components", () => {
const code = compileNativeWireFile(`page Home {
state count = 0
view {
<main class="screen">
<h1>Count {count}</h1>
<button @click="count++">Add</button>
{#if count > 0}<p>Started</p>{:else}<p>Ready</p>{/if}
</main>
}
style { .screen { padding: 24px; background-color: white; } }
}`);
expect(code).toContain("const [count, setCount] = useState(0)");
expect(code).toContain('<View style={[styles["screen"] ]}>');
expect(code).toContain("<Text>Count {count}</Text>");
expect(code).toContain("onPress={() => { setCount(value => value + 1) }}");
expect(code).toContain('"padding": 24');
expect(() => new Bun.Transpiler({ loader: "tsx" }).transformSync(code)).not.toThrow();
});
test("rejects browser-only elements with an actionable error", () => {
expect(() => compileNativeWireFile("page Data { view { <table></table> } }")).toThrow(
NativeCompileError,
);
});
test("compiles loops to native JSX", () => {
const code = compileNativeWireFile(
"page List { view { <ul>{#each items as item, i}<li>{item.name}</li>{:empty}<li>Empty</li>{/each}</ul> } }",
);
expect(code).toContain("(items).map((item, i)");
expect(code).toContain("<Text>{item.name}</Text>");
});
test("selects mobile-only markup and events for native output", () => {
const code = compileNativeWireFile(`page Platforms { view {
<button data-native-only="mobile" @mobile-click="save()" @browser-click="copy()">Save</button>
<p data-native-only="browser">Browser help</p>
} }`);
expect(code).toContain("onPress={() => { save() }}");
expect(code).not.toContain("copy()");
expect(code).not.toContain("Browser help");
});
test("rejects declarative Capacitor actions instead of silently dropping them in Expo", () => {
expect(() =>
compileNativeWireFile(
`page Share { view { <button data-native-mobile="share">Share</button> } }`,
),
).toThrow("currently targets browser/Capacitor pages");
});
test("does not emit native visibility directives as React Native props", () => {
const code = compileNativeWireFile(
`page Support { view { <p data-native-requires="camera">Camera</p> } }`,
);
expect(code).not.toContain("data-native-requires");
});