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
+219 -21
View File
@@ -39,9 +39,12 @@ var __export = (target, all) => {
// ../../packages/compiler/src/index.ts
var exports_src = {};
__export(exports_src, {
runtimeTypeOf: () => runtimeTypeOf,
parse: () => parse,
inferredRuntimeType: () => inferredRuntimeType,
generateNative: () => generateNative,
generate: () => generate,
eraseFunctionTypes: () => eraseFunctionTypes,
compileWireFile: () => compileWireFile,
compileNativeWireFile: () => compileNativeWireFile,
compile: () => compile,
@@ -109,6 +112,9 @@ 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 };
@@ -167,6 +173,67 @@ class Lexer {
v += src[this.pos++];
return v.trim();
}
readTypeAnnotation() {
const { src } = this;
let value = "";
let angle = 0;
let square = 0;
let brace = 0;
let paren = 0;
let quote = 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 type2 = value.trim();
if (!type2)
throw new LexError(`Expected a type annotation at offset ${this.pos}`);
return { type: type2, hasDefault: true };
}
if (c === `
` || 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 };
}
readBalancedBraces() {
this.skipTrivia();
const { src } = this;
@@ -215,6 +282,62 @@ class Lexer {
}
}
// ../../packages/compiler/src/types.ts
function runtimeTypeOf(annotation) {
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";
}
function inferredRuntimeType(expression) {
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";
}
function validateTypedInitializer(name, annotation, expression) {
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}`;
}
function eraseFunctionTypes(source) {
return source.replace(/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g, (_whole, open, params, close, _returnType, brace) => {
const plainParams = params.split(",").map((param) => param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim()).join(", ");
return `${open}${plainParams}${close}${brace}`;
});
}
// ../../packages/compiler/src/parser.ts
var VOID_ELEMENTS = new Set([
"area",
@@ -282,6 +405,7 @@ function parse(source) {
expect("lbrace");
let layout;
const props = [];
const types = [];
const states = [];
const seo = {};
const view = [];
@@ -318,8 +442,19 @@ function parse(source) {
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;
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;
@@ -327,8 +462,23 @@ function parse(source) {
case "state": {
lx.next();
const sName = expect("ident").value;
expect("eq");
states.push({ name: sName, expr: lx.readToLineEnd() });
let valueType;
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": {
@@ -465,12 +615,23 @@ function parse(source) {
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
}
}
for (const prop of props) {
const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
if (problem)
throw new ParseError(problem);
}
for (const state of states) {
const problem = validateTypedInitializer(`State '${state.name}'`, state.valueType, state.expr);
if (problem)
throw new ParseError(problem);
}
return {
type: "page",
kind,
name,
layout,
props,
types,
states,
seo,
view,
@@ -1149,6 +1310,11 @@ function generate(ast) {
`);
const apiBindings = apiBindingMap(ast, helpers);
const typeSource = ast.types.map((body2) => body2.trim()).filter(Boolean).join(`
`);
if (typeSource)
out.push(typeSource);
if (helpers) {
out.push(`// --- .wrn functions ---
${helpers}`);
@@ -1177,6 +1343,7 @@ ${css}
}
let body = templateEscape(html);
const dynamicStateScope = ast.states.map((state) => `${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);
});
@@ -1200,7 +1367,7 @@ ${css}
` : "";
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]) => {
@@ -1226,7 +1393,7 @@ ${css}
}`);
} 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]) => {
@@ -1346,9 +1513,9 @@ function escLit(s) {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast) {
const functions = ast.functions.map((body) => body.trim()).filter(Boolean).join(`
const functions = eraseFunctionTypes(ast.functions.map((body) => body.trim()).filter(Boolean).join(`
`);
`));
const lifecycle = {
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
@@ -1562,7 +1729,9 @@ function generateComponent(ast) {
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
{
name: "content",
default: '""'
default: '""',
valueType: "string",
required: false
},
...ast.props
] : ast.props;
@@ -1600,10 +1769,13 @@ ${styles.map(styleEscape).join(`
const behaviorAttr = behaviorAttribute(behavior);
const decls = [];
for (const prop of effectiveProps) {
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
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)}: ${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 ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`).join(", ")} });
@@ -1617,20 +1789,35 @@ ${styles.map(styleEscape).join(`
if (behavior) {
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(`
`);
if (typeSource)
out.push(typeSource);
if (effectiveProps.length > 0) {
out.push(`export interface ${ast.name}Props {
${effectiveProps.map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`).join(`
`)}
}`);
}
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;
}
@@ -1640,6 +1827,7 @@ ${styles.map(styleEscape).join(`
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : def;
} catch {
if (declared === "array") throw new TypeError("Expected an array prop");
return def;
}
}
@@ -1647,7 +1835,7 @@ ${styles.map(styleEscape).join(`
return def;
}
if (def !== null && typeof def === "object") {
if (declared === "object" || (def !== null && typeof def === "object")) {
if (
v !== null &&
typeof v === "object" &&
@@ -1668,6 +1856,7 @@ ${styles.map(styleEscape).join(`
? parsed
: def;
} catch {
if (declared === "object") throw new TypeError("Expected an object prop");
return def;
}
}
@@ -1675,7 +1864,11 @@ ${styles.map(styleEscape).join(`
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 {
@@ -1775,7 +1968,7 @@ function __wireRaw(v: any): string {
}
const serverFunctionSource = serverFunctions ? `${serverFunctions}
` : "";
out.push(`export function render(props: Record<string, any> = {}): string {
out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): string {
` + ` const __p = props || {};
` + (decls.length > 0 ? decls.join(`
`) + `
@@ -1988,15 +2181,20 @@ function generateNative(ast) {
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});`;
return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`;
}).join(`
`);
const body = ast.view.map((node) => renderNode2(node, states)).join("");
const typeSource = ast.types.map((block) => block.trim()).filter(Boolean).join(`
`);
return `// generated from .wrn for Expo/React Native
import React, { useState } from "react";
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
import { useRouter } from "expo-router";
${typeSource}
export default function ${ast.name}() {
const router = useRouter();
${hooks}