feat: add typed WRN declarations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -593,6 +593,15 @@ const MIGRATIONS: Migration[] = [
|
||||
// UI components remain auto-discovered; no application files require migration.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.59",
|
||||
id: "typed-wrn-declarations",
|
||||
description:
|
||||
"Adds explicit prop and state types, local type declarations, and typed component runtime validation.",
|
||||
apply() {
|
||||
// Existing inferred declarations remain compatible; typed syntax is opt-in.
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
+17
-15
@@ -76,18 +76,19 @@ class Lexer {
|
||||
|
||||
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. |
|
||||
| Type | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PageAst` | Root node including `kind`, `name`, `types`, typed `props`, typed `states`, `view`, styles, functions, data APIs, lifecycle, and routes. |
|
||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||
| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = <expr>` declaration. |
|
||||
| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop 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
|
||||
|
||||
@@ -147,12 +148,13 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
||||
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.
|
||||
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file.
|
||||
- `props { name: Type = <default> ... }` — typed component props. Omit `= <default>` to make a prop required. Legacy inferred props remain supported.
|
||||
- `state <ident>: Type = <expr>` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
||||
- `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).
|
||||
- `functions { <TypeScript> }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code.
|
||||
- `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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
@@ -667,6 +668,12 @@ export function generate(ast: PageAst): string {
|
||||
.join("\n\n");
|
||||
const apiBindings = apiBindingMap(ast, helpers);
|
||||
|
||||
const typeSource = ast.types
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (typeSource) out.push(typeSource);
|
||||
|
||||
if (helpers) {
|
||||
out.push(`// --- .wrn functions ---\n${helpers}`);
|
||||
}
|
||||
@@ -709,6 +716,12 @@ export function generate(ast: PageAst): string {
|
||||
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
|
||||
)
|
||||
.join(", ");
|
||||
const stateType =
|
||||
ast.states.length > 0
|
||||
? `{ ${ast.states
|
||||
.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`)
|
||||
.join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
@@ -738,7 +751,7 @@ export function generate(ast: PageAst): string {
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: any) {
|
||||
${decls}
|
||||
const __state = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
@@ -766,7 +779,7 @@ export function generate(ast: PageAst): string {
|
||||
} else {
|
||||
out.push(
|
||||
`export default function ${ast.name}(ctx: any) {
|
||||
const __state = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
@@ -932,10 +945,12 @@ function escLit(s: string): string {
|
||||
}
|
||||
|
||||
function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
const functions = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const functions = eraseFunctionTypes(
|
||||
ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
|
||||
const lifecycle = {
|
||||
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
|
||||
@@ -1289,6 +1304,8 @@ function generateComponent(ast: PageAst): string {
|
||||
{
|
||||
name: "content",
|
||||
default: '""',
|
||||
valueType: "string",
|
||||
required: false,
|
||||
},
|
||||
...ast.props,
|
||||
]
|
||||
@@ -1339,12 +1356,21 @@ function generateComponent(ast: PageAst): string {
|
||||
|
||||
const decls: string[] = [];
|
||||
for (const prop of effectiveProps) {
|
||||
if (prop.required) {
|
||||
decls.push(
|
||||
` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(
|
||||
`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`,
|
||||
)});`,
|
||||
);
|
||||
}
|
||||
decls.push(
|
||||
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
|
||||
` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`,
|
||||
);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
||||
decls.push(
|
||||
` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`,
|
||||
);
|
||||
}
|
||||
|
||||
const returnExpr = needsScope
|
||||
@@ -1370,20 +1396,41 @@ function generateComponent(ast: PageAst): string {
|
||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
||||
}
|
||||
|
||||
out.push(`function __coerce(v: any, def: any): any {
|
||||
const typeSource = ast.types
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (typeSource) out.push(typeSource);
|
||||
|
||||
if (effectiveProps.length > 0) {
|
||||
out.push(
|
||||
`export interface ${ast.name}Props {\n${effectiveProps
|
||||
.map(
|
||||
(prop) =>
|
||||
` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
|
||||
)
|
||||
.join("\n")}\n}`,
|
||||
);
|
||||
}
|
||||
|
||||
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||
if (v === undefined || v === null) {
|
||||
return def;
|
||||
}
|
||||
|
||||
if (typeof def === "number") {
|
||||
return Number(v);
|
||||
if (declared === "number" || typeof def === "number") {
|
||||
const parsed = Number(v);
|
||||
if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (typeof def === "boolean") {
|
||||
return v === true || v === "" || v === "true";
|
||||
if (declared === "boolean" || typeof def === "boolean") {
|
||||
if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true;
|
||||
if (v === false || v === "false" || v === 0 || v === "0") return false;
|
||||
throw new TypeError("Expected a boolean prop");
|
||||
}
|
||||
|
||||
if (Array.isArray(def)) {
|
||||
if (declared === "array" || Array.isArray(def)) {
|
||||
if (Array.isArray(v)) {
|
||||
return v;
|
||||
}
|
||||
@@ -1393,6 +1440,7 @@ function generateComponent(ast: PageAst): string {
|
||||
const parsed = JSON.parse(v);
|
||||
return Array.isArray(parsed) ? parsed : def;
|
||||
} catch {
|
||||
if (declared === "array") throw new TypeError("Expected an array prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -1400,7 +1448,7 @@ function generateComponent(ast: PageAst): string {
|
||||
return def;
|
||||
}
|
||||
|
||||
if (def !== null && typeof def === "object") {
|
||||
if (declared === "object" || (def !== null && typeof def === "object")) {
|
||||
if (
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
@@ -1421,6 +1469,7 @@ function generateComponent(ast: PageAst): string {
|
||||
? parsed
|
||||
: def;
|
||||
} catch {
|
||||
if (declared === "object") throw new TypeError("Expected an object prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -1428,7 +1477,11 @@ function generateComponent(ast: PageAst): string {
|
||||
return def;
|
||||
}
|
||||
|
||||
return String(v);
|
||||
if (declared === "bigint") return BigInt(v);
|
||||
if (declared === "function" && typeof v !== "function") {
|
||||
throw new TypeError("Expected a function prop");
|
||||
}
|
||||
return declared === "unknown" && def === undefined ? v : String(v);
|
||||
}
|
||||
|
||||
function __wireHtml(v: any): string {
|
||||
@@ -1532,7 +1585,7 @@ function __wireRaw(v: any): string {
|
||||
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
|
||||
|
||||
out.push(
|
||||
`export function render(props: Record<string, any> = {}): string {\n` +
|
||||
`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): string {\n` +
|
||||
` const __p = props || {};\n` +
|
||||
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
|
||||
serverFunctionSource +
|
||||
|
||||
@@ -15,12 +15,14 @@ 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 { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "./types.ts";
|
||||
export type {
|
||||
PageAst,
|
||||
SeoBlock,
|
||||
ViewNode,
|
||||
Attr,
|
||||
StateDecl,
|
||||
PropDecl,
|
||||
ApiBlock,
|
||||
DataApiBlock,
|
||||
DataMode,
|
||||
|
||||
@@ -225,9 +225,13 @@ export function generateNative(ast: PageAst): string {
|
||||
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});`;
|
||||
return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${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`;
|
||||
const typeSource = ast.types
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
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\n${typeSource}\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Grammar (subset of the vision, but real):
|
||||
*
|
||||
* page <Name> {
|
||||
* state <ident> = <expr> // zero or more
|
||||
* types { <TypeScript declarations> }
|
||||
* props { <ident>: <type> [= <expr>] } // no default means required
|
||||
* state <ident>: <type> = <expr> // type annotation is optional
|
||||
* view { <html> } // plain HTML (see parseHtmlView)
|
||||
* seo { title = "Home" description = "..." }
|
||||
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||
@@ -21,9 +23,12 @@
|
||||
*/
|
||||
|
||||
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
||||
import { validateTypedInitializer } from "./types.ts";
|
||||
|
||||
export interface StateDecl {
|
||||
name: string;
|
||||
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||
valueType?: string;
|
||||
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
||||
expr: string;
|
||||
}
|
||||
@@ -131,6 +136,10 @@ export interface RealtimeBlock {
|
||||
|
||||
export interface PropDecl {
|
||||
name: string;
|
||||
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||
valueType?: string;
|
||||
/** Props without a default are required. */
|
||||
required: boolean;
|
||||
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
||||
default: string;
|
||||
}
|
||||
@@ -147,6 +156,8 @@ export interface PageAst {
|
||||
layout?: string;
|
||||
/** Declared component props (empty for pages). */
|
||||
props: PropDecl[];
|
||||
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
||||
types: string[];
|
||||
states: StateDecl[];
|
||||
seo: SeoBlock;
|
||||
view: ViewNode[];
|
||||
@@ -218,6 +229,7 @@ export function parse(source: string): PageAst {
|
||||
|
||||
let layout: string | undefined;
|
||||
const props: PropDecl[] = [];
|
||||
const types: string[] = [];
|
||||
const states: StateDecl[] = [];
|
||||
const seo: SeoBlock = {};
|
||||
const view: ViewNode[] = [];
|
||||
@@ -245,7 +257,7 @@ export function parse(source: string): PageAst {
|
||||
break;
|
||||
}
|
||||
case "props": {
|
||||
// props { name = <default> ... } — one declaration per line.
|
||||
// props { name: Type = <default> } — omit the default for required props.
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
@@ -255,8 +267,19 @@ export function parse(source: string): PageAst {
|
||||
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() });
|
||||
let valueType: string | undefined;
|
||||
let hasDefault = false;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
hasDefault = annotation.hasDefault;
|
||||
} else {
|
||||
expect("eq");
|
||||
hasDefault = true;
|
||||
}
|
||||
const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined";
|
||||
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
@@ -264,8 +287,23 @@ export function parse(source: string): PageAst {
|
||||
case "state": {
|
||||
lx.next();
|
||||
const sName = expect("ident").value;
|
||||
expect("eq");
|
||||
states.push({ name: sName, expr: lx.readToLineEnd() });
|
||||
let valueType: string | undefined;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault) {
|
||||
throw new ParseError(`State '${sName}' requires an initializer`);
|
||||
}
|
||||
} else {
|
||||
expect("eq");
|
||||
}
|
||||
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
|
||||
break;
|
||||
}
|
||||
case "types": {
|
||||
lx.next();
|
||||
types.push(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "view": {
|
||||
@@ -421,12 +459,26 @@ export function parse(source: string): PageAst {
|
||||
}
|
||||
}
|
||||
|
||||
for (const prop of props) {
|
||||
const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
|
||||
if (problem) throw new ParseError(problem);
|
||||
}
|
||||
for (const state of states) {
|
||||
const problem = validateTypedInitializer(
|
||||
`State '${state.name}'`,
|
||||
state.valueType,
|
||||
state.expr,
|
||||
);
|
||||
if (problem) throw new ParseError(problem);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "page",
|
||||
kind,
|
||||
name,
|
||||
layout,
|
||||
props,
|
||||
types,
|
||||
states,
|
||||
seo,
|
||||
view,
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
"ident" | "string" | "lbrace" | "rbrace" | "lparen" | "rparen" | "at" | "eq" | "comma" | "eof";
|
||||
| "ident"
|
||||
| "string"
|
||||
| "lbrace"
|
||||
| "rbrace"
|
||||
| "lparen"
|
||||
| "rparen"
|
||||
| "at"
|
||||
| "eq"
|
||||
| "colon"
|
||||
| "comma"
|
||||
| "eof";
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
@@ -71,6 +81,9 @@ export class Lexer {
|
||||
case "=":
|
||||
this.pos++;
|
||||
return { type: "eq", value: c, pos };
|
||||
case ":":
|
||||
this.pos++;
|
||||
return { type: "colon", value: c, pos };
|
||||
case ",":
|
||||
this.pos++;
|
||||
return { type: "comma", value: c, pos };
|
||||
@@ -133,6 +146,62 @@ export class Lexer {
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a TypeScript-style type annotation after `:`. Reading stops at a
|
||||
* top-level `=` or line ending, while nested object/tuple/generic syntax is
|
||||
* preserved. The optional `=` is consumed for the caller.
|
||||
*/
|
||||
readTypeAnnotation(): { type: string; hasDefault: boolean } {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
if (quote) {
|
||||
value += c;
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length) value += src[this.pos++]!;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
value += c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
|
||||
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||
if (c === "=") {
|
||||
this.pos++;
|
||||
const type = value.trim();
|
||||
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type, hasDefault: true };
|
||||
}
|
||||
if (c === "\n" || c === "\r") break;
|
||||
}
|
||||
value += c;
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const type = value.trim();
|
||||
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type, hasDefault: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `{ ... }` block and return its INNER text (no outer braces), with
|
||||
* brace counting that respects string and template literals so a `}` inside a
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Utilities shared by typed `.wrn` parsing, validation, and code generation. */
|
||||
|
||||
export type RuntimeType =
|
||||
"string" | "number" | "boolean" | "bigint" | "array" | "object" | "function" | "unknown";
|
||||
|
||||
export function runtimeTypeOf(annotation: string | undefined): RuntimeType {
|
||||
if (!annotation) return "unknown";
|
||||
const type = annotation.trim().replace(/^readonly\s+/, "");
|
||||
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "string";
|
||||
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "number";
|
||||
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "boolean";
|
||||
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "bigint";
|
||||
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type)) return "array";
|
||||
if (/^(?:Record\s*<|object\b|\{)/.test(type)) return "object";
|
||||
if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type)) return "function";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function inferredRuntimeType(expression: string): RuntimeType {
|
||||
const value = expression.trim();
|
||||
if (/^["'`]/.test(value)) return "string";
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return "number";
|
||||
if (/^(?:true|false)$/.test(value)) return "boolean";
|
||||
if (/^-?\d+n$/.test(value)) return "bigint";
|
||||
if (value.startsWith("[")) return "array";
|
||||
if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value)) return "object";
|
||||
if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) {
|
||||
return "function";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function validateTypedInitializer(
|
||||
name: string,
|
||||
annotation: string | undefined,
|
||||
expression: string,
|
||||
): string | null {
|
||||
if (!annotation || expression.trim() === "undefined" || expression.trim() === "null") return null;
|
||||
const expected = runtimeTypeOf(annotation);
|
||||
const actual = inferredRuntimeType(expression);
|
||||
if (expected === "unknown" || actual === "unknown" || expected === actual) return null;
|
||||
return `${name} is declared as ${annotation}, but its initializer is ${actual}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser behavior is evaluated as JavaScript, so erase TypeScript annotations
|
||||
* from ordinary function declarations before serializing it into HTML.
|
||||
* Server output retains the original typed source.
|
||||
*/
|
||||
export function eraseFunctionTypes(source: string): string {
|
||||
return source.replace(
|
||||
/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g,
|
||||
(_whole, open: string, params: string, close: string, _returnType: string, brace: string) => {
|
||||
const plainParams = params
|
||||
.split(",")
|
||||
.map((param) =>
|
||||
param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim(),
|
||||
)
|
||||
.join(", ");
|
||||
return `${open}${plainParams}${close}${brace}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,80 @@ test("parses a component with props and state", () => {
|
||||
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
||||
});
|
||||
|
||||
test("typed props, required props, state, custom types, and function parameters compile", () => {
|
||||
const source = `component TypedPicker {
|
||||
types {
|
||||
interface DateOption { value: string; label: string }
|
||||
type ChangeHandler = (value: string) => void
|
||||
}
|
||||
props {
|
||||
value: string = ""
|
||||
options: DateOption[] = []
|
||||
required: boolean = false
|
||||
onChange: ChangeHandler
|
||||
}
|
||||
state open: boolean = false
|
||||
functions {
|
||||
function choose(option: DateOption, index: number): void {
|
||||
open = false
|
||||
onChange(option.value)
|
||||
}
|
||||
}
|
||||
view { <button @click="choose(options[0], 0)">{value}</button> }
|
||||
}`;
|
||||
const ast = parse(source);
|
||||
expect(ast.props.map(({ name, valueType, required }) => ({ name, valueType, required }))).toEqual(
|
||||
[
|
||||
{ name: "value", valueType: "string", required: false },
|
||||
{ name: "options", valueType: "DateOption[]", required: false },
|
||||
{ name: "required", valueType: "boolean", required: false },
|
||||
{ name: "onChange", valueType: "ChangeHandler", required: true },
|
||||
],
|
||||
);
|
||||
expect(ast.states[0]).toMatchObject({ name: "open", valueType: "boolean", expr: "false" });
|
||||
const output = generate(ast);
|
||||
expect(output).toContain("interface DateOption");
|
||||
expect(output).toContain("export interface TypedPickerProps");
|
||||
expect(output).toContain('"onChange": ChangeHandler;');
|
||||
expect(output).toContain("let open: boolean = (false)");
|
||||
expect(output).toContain("function choose(option: DateOption, index: number): void");
|
||||
const behavior = extractCompiledBehavior(output);
|
||||
expect(behavior.functions).toContain("function choose(option, index)");
|
||||
expect(behavior.functions).not.toContain("option: DateOption");
|
||||
});
|
||||
|
||||
test("typed declarations reject obvious initializer mismatches", () => {
|
||||
expect(() =>
|
||||
parse(`component Invalid {
|
||||
props {
|
||||
required: boolean = "yes"
|
||||
}
|
||||
view { <div></div> }
|
||||
}`),
|
||||
).toThrow("Prop 'required' is declared as boolean, but its initializer is string");
|
||||
expect(() => parse(`page Invalid { state count: number = false\n view { <p></p> } }`)).toThrow(
|
||||
"State 'count' is declared as number, but its initializer is boolean",
|
||||
);
|
||||
});
|
||||
|
||||
test("typed props enforce required values and runtime-compatible input", async () => {
|
||||
const component = await compileAndImport(`component TypedInput {
|
||||
props {
|
||||
label: string
|
||||
count: number = 0
|
||||
enabled: boolean = false
|
||||
}
|
||||
view { <span>{label}:{count}:{enabled}</span> }
|
||||
}`);
|
||||
const render = component.render as (props?: Record<string, unknown>) => string;
|
||||
expect(() => render()).toThrow("TypedInput requires prop 'label' (string)");
|
||||
expect(render({ label: "Total", count: "4", enabled: "true" })).toContain(
|
||||
"<span>Total:4:true</span>",
|
||||
);
|
||||
expect(() => render({ label: "Total", count: "many" })).toThrow("Expected a finite number prop");
|
||||
expect(() => render({ label: "Total", enabled: "sometimes" })).toThrow("Expected a boolean prop");
|
||||
});
|
||||
|
||||
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}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
component DateInput {
|
||||
props {
|
||||
id = ""
|
||||
name = ""
|
||||
label = "Date"
|
||||
value = ""
|
||||
placeholder = ""
|
||||
help = ""
|
||||
error = ""
|
||||
required = false
|
||||
disabled = false
|
||||
readonly = false
|
||||
autocomplete = ""
|
||||
id: string = ""
|
||||
name: string = ""
|
||||
label: string = "Date"
|
||||
value: string = ""
|
||||
placeholder: string = ""
|
||||
help: string = ""
|
||||
error: string = ""
|
||||
required: boolean = false
|
||||
disabled: boolean = false
|
||||
readonly: boolean = false
|
||||
autocomplete: string = ""
|
||||
}
|
||||
view {
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
component DatePicker {
|
||||
props {
|
||||
id = "date"
|
||||
name = "date"
|
||||
label = "Date"
|
||||
value = ""
|
||||
min = ""
|
||||
max = ""
|
||||
required = false
|
||||
id: string = "date"
|
||||
name: string = "date"
|
||||
label: string = "Date"
|
||||
value: string = ""
|
||||
min: string = ""
|
||||
max: string = ""
|
||||
required: boolean = false
|
||||
}
|
||||
view {
|
||||
<DateInput id="{id}" name="{name}" label="{label}" value="{value}" required="{required}" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.58",
|
||||
"version": "0.2.59",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user