feat: add typed WRN declarations

This commit is contained in:
2026-07-19 18:44:27 +05:30
parent 0d3ec79ee4
commit 94ae40d8bd
81 changed files with 942 additions and 241 deletions
+70 -17
View File
@@ -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 +
+2
View File
@@ -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,
+6 -2
View File
@@ -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`;
}
+58 -6
View File
@@ -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,
+70 -1
View File
@@ -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
+63
View File
@@ -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}`;
},
);
}