first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
/** 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 = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||
}
|
||||
function __wireAttr(v: any): string {
|
||||
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
|
||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||
);
|
||||
}`);
|
||||
|
||||
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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}`);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <b>"); // 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");
|
||||
});
|
||||
Reference in New Issue
Block a user