release: WRNexusJS 0.3.0

This commit is contained in:
2026-07-22 17:29:08 +05:30
parent 13dfa31d19
commit 07d8fb59d6
145 changed files with 9664 additions and 3881 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -20,6 +20,8 @@
"@wrnexus/ui": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/i18n": "workspace:*",
"@wrnexus/db": "workspace:*"
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/syntax": "workspace:*"
}
}
+56
View File
@@ -0,0 +1,56 @@
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
interface BuildReport {
frameworkVersion: string;
generatedAt: string;
adapter: string;
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
assets: Array<{ file: string; bytes: number }>;
measurements: Record<string, number>;
budgetViolations: Array<{ metric: string; budget: number; actual: number; overBy: number }>;
}
function bytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(2)} MB`;
}
export function runAnalyze(appRoot: string, args: string[]): boolean {
const root = resolve(appRoot);
const path = join(root, "dist", "build-report.json");
if (!existsSync(path)) {
console.error("WRN-BUILD-REPORT-MISSING: run `wrnexus build` before `wrnexus analyze`.");
return false;
}
const report = JSON.parse(readFileSync(path, "utf8")) as BuildReport;
if (args.includes("--json")) {
console.log(JSON.stringify(report, null, 2));
return report.budgetViolations.length === 0;
}
console.log(`WRNexus build analysis (${report.frameworkVersion})`);
console.log(` Generated: ${report.generatedAt}`);
console.log(` Adapter: ${report.adapter}`);
console.log(` Routes: ${report.routes.length}`);
console.log("\nMeasurements:");
for (const [metric, value] of Object.entries(report.measurements)) {
console.log(` ${metric.padEnd(18)} ${bytes(value)}`);
}
console.log("\nLargest assets:");
for (const asset of report.assets.slice(0, 12)) {
console.log(` ${bytes(asset.bytes).padStart(10)} ${asset.file}`);
}
if (report.budgetViolations.length) {
console.log("\nBudget violations:");
for (const violation of report.budgetViolations) {
console.log(
`${violation.metric}: ${bytes(violation.actual)} > ${bytes(violation.budget)}`,
);
}
} else {
console.log("\n ✓ No configured performance budget violations.");
}
return report.budgetViolations.length === 0;
}
+162 -18
View File
@@ -11,11 +11,20 @@
*/
import { createHash } from "node:crypto";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getReactiveRuntime } from "@wrnexus/csr";
import { compileWireFile } from "@wrnexus/compiler";
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
import {
loadAppConfig,
headToString,
@@ -30,6 +39,8 @@ import { uiComponentsDir, uiCss } from "@wrnexus/ui";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
import { checkPerformanceBudgets } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
// Import the production server from the package specifier (not a source path) so
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
@@ -58,23 +69,47 @@ export async function runBuild(appRoot: string): Promise<void> {
console.log(`✓ Public: ${distPublicDir}`);
}
// `.wrn` route files are compiled to `.ts` so Bun.build can bundle them.
let compiledCount = 0;
const importPathFor = (file: string): string => {
if (!file.endsWith(".wrn")) {
return fwd(file);
}
const ts = compileWireFile(readFileSync(file, "utf8"), file);
const out = join(compiledDir, `route${compiledCount++}.ts`);
writeFileSync(out, ts, "utf8");
return fwd(out);
};
const config = await loadAppConfig(root);
const pluginRunner = createPluginRunner(config.plugins, {
root,
mode: "production",
command: "build",
profile: process.env.WRNEXUS_PROFILE,
metadata: new Map(),
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
await pluginRunner.configure(config as Record<string, unknown>);
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
await pluginRunner.hook("buildStart");
// `.wrn` route files are compiled once into deterministic intermediate modules.
// Plugin AST/code transforms run only when configured, so existing applications
// keep the exact compiler path and output contract by default.
let compiledCount = 0;
const compiledFiles = new Map<string, string>();
const compileWrn = async (file: string): Promise<void> => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8");
let ast = parse(source);
assertValidAst(ast, { file, accessibility: true });
ast = await pluginRunner.transformAst(ast, file);
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
console.warn(`[${diagnostic.code}] ${file}: ${diagnostic.message}`);
}
if (errors.length) {
throw new Error(
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
);
}
let code = `// compiled from .wrn\n${generate(ast)}`;
code = await pluginRunner.transformCode(code, file);
const out = join(compiledDir, `route${compiledCount++}.ts`);
writeFileSync(out, code, "utf8");
compiledFiles.set(file, out);
};
const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file);
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
// any page/API importing them is built against the current SQL.
const { regenerateQueries } = await import("./db.ts");
@@ -105,6 +140,14 @@ export async function runBuild(appRoot: string): Promise<void> {
}
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
const wrnFiles = new Set([
...router.pages.map((route) => route.file),
...router.api.map((route) => route.file),
...router.realtime.map((route) => route.file),
...router.components.map((component) => component.file),
...router.layouts.map((layout) => layout.file),
]);
for (const file of wrnFiles) await compileWrn(file);
const assetHash = createHash("sha256");
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
@@ -271,6 +314,8 @@ await createProductionServer(
mobile: ${JSON.stringify(config.mobile ?? {})},
pwa: ${JSON.stringify(config.pwa ?? {})},
security: ${JSON.stringify(config.security ?? {})},
observability: ${JSON.stringify(config.observability ?? {})},
tenancy: ${JSON.stringify(config.tenancy ?? {})},
},
);
`;
@@ -285,19 +330,118 @@ await createProductionServer(
target: "bun",
format: "esm",
minify: true,
sourcemap: config.build?.sourceMaps ? "inline" : "none",
});
if (!result.success) {
throw new Error("Server build failed:\n" + result.logs.map(String).join("\n"));
}
writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8");
const report = createBuildReport({
root,
distDir,
publicDir: distPublicDir,
adapter: config.build?.adapter ?? "bun",
routes: router.pages,
runtimeFile: reactivePath,
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
});
const violations = checkPerformanceBudgets(
config.performance?.budgets ?? {},
report.measurements,
);
report.budgetViolations = violations;
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
await pluginRunner.hook("buildEnd", report);
console.log(`✓ Server: ${join(distDir, "server.js")}`);
console.log(
`✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`,
);
console.log(`✓ Report: ${join(distDir, "build-report.json")}`);
if (violations.length) {
for (const violation of violations) {
console.warn(
`⚠ Budget ${violation.metric}: ${violation.actual} > ${violation.budget} (+${violation.overBy})`,
);
}
if (config.performance?.enforcement === "error") {
throw new Error(
`WRN-PERFORMANCE-BUDGET: ${violations.length} production budget(s) exceeded.`,
);
}
}
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
}
interface BuildReport {
frameworkVersion: string;
generatedAt: string;
root: string;
adapter: string;
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
assets: Array<{ file: string; bytes: number }>;
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
budgetViolations: ReturnType<typeof checkPerformanceBudgets>;
}
function fileBytes(file: string): number {
return existsSync(file) && statSync(file).isFile() ? statSync(file).size : 0;
}
function walkFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) files.push(...walkFiles(path));
else if (stat.isFile()) files.push(path);
}
return files;
}
function createBuildReport(input: {
root: string;
distDir: string;
publicDir: string;
adapter: string;
routes: Route[];
runtimeFile: string;
cssFile: string;
}): BuildReport {
const assets = walkFiles(input.distDir)
.filter((file) => !file.endsWith("build-report.json") && !fwd(file).includes("/compiled/"))
.map((file) => ({ file: fwd(file.slice(input.distDir.length + 1)), bytes: fileBytes(file) }))
.sort((a, b) => b.bytes - a.bytes);
const imageExtensions = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i;
const imageBytes = Math.max(
0,
...walkFiles(input.publicDir)
.filter((file) => imageExtensions.test(file))
.map(fileBytes),
);
return {
frameworkVersion: "0.3.0",
generatedAt: new Date().toISOString(),
root: input.root,
adapter: input.adapter,
routes: input.routes.map((route) => ({
path: route.raw,
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
sourceBytes: fileBytes(route.file),
dynamicParams: route.paramNames,
})),
assets,
measurements: {
routeJsBytes: fileBytes(input.runtimeFile),
routeCssBytes: fileBytes(input.cssFile),
imageBytes,
},
budgetViolations: [],
};
}
async function buildBrowserRuntime(
source: string,
outFile: string,
+29
View File
@@ -0,0 +1,29 @@
import { resolve } from "node:path";
import { explainAppConfig } from "@wrnexus/styles";
function jsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
return value;
}
export async function runConfigCommand(appRoot: string, args: string[]): Promise<void> {
const root = resolve(appRoot);
const profile = args.find((arg) => arg.startsWith("--profile="))?.split("=")[1];
const explained = await explainAppConfig(root, profile);
if (args.includes("--json")) {
console.log(JSON.stringify(explained, jsonReplacer, 2));
return;
}
console.log(`WRNexus resolved configuration\n`);
console.log(` Root: ${root}`);
console.log(` Profile: ${explained.profile}`);
console.log(` Sources: ${explained.sources.join(", ") || "framework defaults"}`);
if (explained.issues.length) {
console.log("\nIssues:");
for (const issue of explained.issues) {
console.log(` ${issue.severity === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
}
}
console.log("\nResolved value:\n");
console.log(JSON.stringify(explained.config, jsonReplacer, 2));
}
+147 -14
View File
@@ -1,26 +1,99 @@
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import { diagnose } from "@wrnexus/syntax";
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
export interface DoctorCheck {
name: string;
ok: boolean;
detail: string;
level?: "error" | "warning";
}
function parseVersion(value: string): [number, number, number] {
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^[^\d]*/, ""));
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0];
}
function versionAtLeast(value: string, minimum: string): boolean {
const left = parseVersion(value);
const right = parseVersion(minimum);
for (let i = 0; i < 3; i++) {
if (left[i] !== right[i]) return left[i]! > right[i]!;
}
return true;
}
function walk(dir: string, extension: string): string[] {
if (!existsSync(dir)) return [];
const files: string[] = [];
for (const entry of readdirSync(dir)) {
if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue;
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) files.push(...walk(path, extension));
else if (stat.isFile() && extname(path) === extension) files.push(path);
}
return files;
}
function frameworkRanges(pkg: Record<string, unknown>): Map<string, string[]> {
const ranges = new Map<string, string[]>();
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
const deps = pkg[field] as Record<string, string> | undefined;
for (const [name, range] of Object.entries(deps ?? {})) {
if (!name.startsWith("@wrnexus/")) continue;
const values = ranges.get(range) ?? [];
values.push(name);
ranges.set(range, values);
}
}
return ranges;
}
export function inspectProject(appRoot: string): DoctorCheck[] {
const root = resolve(appRoot);
const checks: DoctorCheck[] = [];
const pkgPath = join(root, "package.json");
const bunVersion = typeof Bun !== "undefined" ? String(Bun.version) : "";
checks.push({
name: "Bun runtime",
ok: typeof Bun !== "undefined",
detail: typeof Bun !== "undefined" ? `v${Bun.version}` : "Bun is required",
ok: !!bunVersion && versionAtLeast(bunVersion, "1.3.0"),
detail: bunVersion ? `v${bunVersion} (minimum 1.3.0)` : "Bun 1.3.0 or newer is required",
});
checks.push({
name: "package.json",
ok: existsSync(pkgPath),
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WrNexus project root",
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WRNexus project root",
});
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
const ranges = frameworkRanges(pkg);
checks.push({
name: "framework package versions",
ok: ranges.size <= 1,
detail:
ranges.size <= 1
? ([...ranges.keys()][0] ?? "No @wrnexus packages declared")
: `version skew: ${[...ranges.entries()]
.map(([range, names]) => `${range} (${names.join(", ")})`)
.join("; ")}`,
});
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
checks.push({
name: "update marker",
ok: !marker || versionAtLeast(marker, "0.3.0"),
detail: marker ? `project last migrated to ${marker}` : "missing; run `wrnexus update`",
level: "warning",
});
} catch {
checks.push({ name: "package JSON", ok: false, detail: "package.json is invalid JSON" });
}
}
const app = join(root, "app");
checks.push({
name: "app/pages",
@@ -34,13 +107,51 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
name: "configuration",
ok: !!config,
detail: config ?? "No wrnexus.config file; framework defaults will be used",
level: "warning",
});
const wrnFiles = walk(app, ".wrn");
let syntaxErrors = 0;
let syntaxWarnings = 0;
for (const file of wrnFiles) {
const diagnostics = diagnose(readFileSync(file, "utf8"), { file, accessibility: true });
syntaxErrors += diagnostics.filter((item) => item.severity === "error").length;
syntaxWarnings += diagnostics.filter((item) => item.severity !== "error").length;
}
checks.push({
name: "WRN language",
ok: syntaxErrors === 0,
detail: `${wrnFiles.length} files, ${syntaxErrors} errors, ${syntaxWarnings} warnings`,
});
if (existsSync(join(app, "pages"))) {
try {
const router = buildRouter(app);
const conflicts = [
...findRouteConflicts(router.pages),
...findRouteConflicts(router.api),
...findRouteConflicts(router.realtime),
];
checks.push({
name: "route manifest",
ok: conflicts.length === 0,
detail: conflicts.length
? conflicts.map((conflict) => conflict.raw).join(", ")
: `${router.pages.length} pages, ${router.api.length} API, ${router.realtime.length} realtime`,
});
} catch (error) {
checks.push({
name: "route manifest",
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
}
const mobilePkg = join(root, "mobile", "package.json");
if (existsSync(mobilePkg)) {
try {
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as {
wrnexus?: { mode?: string };
};
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as { wrnexus?: { mode?: string } };
checks.push({
name: "mobile project",
ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native",
@@ -57,12 +168,34 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
return checks;
}
export function runDoctor(appRoot: string): boolean {
const checks = inspectProject(appRoot);
console.log("WrNexus doctor\n");
for (const check of checks)
console.log(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
export async function runDoctor(appRoot: string): Promise<boolean> {
const root = resolve(appRoot);
const checks = inspectProject(root);
try {
const config = await loadAppConfig(root);
const issues = validateAppConfig(config);
checks.push({
name: "resolved configuration",
ok: issues.every((issue) => issue.severity !== "error"),
detail: issues.length
? issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")
: "valid",
level: issues.some((issue) => issue.severity === "error") ? "error" : "warning",
});
} catch (error) {
checks.push({
name: "resolved configuration",
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
console.log("WRNexus doctor\n");
for (const check of checks) {
const optional = check.level === "warning";
console.log(` ${check.ok ? "✓" : optional ? "⚠" : "✗"} ${check.name}: ${check.detail}`);
}
console.log("\n Security dependencies: run `bun audit`");
console.log(" Complete verification: run `bun run check`");
return checks.every((check) => check.ok || check.name === "configuration");
return checks.every((check) => check.ok || check.level === "warning");
}
+15 -2
View File
@@ -60,7 +60,9 @@ Usage:
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile)
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
wrnexus doctor [app-dir] Check project structure, runtime, mobile config, and next fixes
wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
@@ -181,7 +183,18 @@ async function main(): Promise<void> {
}
case "doctor": {
const { runDoctor } = await import("./doctor.ts");
const healthy = runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
if (!healthy) process.exitCode = 1;
break;
}
case "config": {
const { runConfigCommand } = await import("./config-command.ts");
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
break;
}
case "analyze": {
const { runAnalyze } = await import("./analyze.ts");
const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
if (!healthy) process.exitCode = 1;
break;
}
+191 -21
View File
@@ -12,12 +12,20 @@
* the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest
* update` to jump to the newest release with no network guesswork).
*
* Migrations are CONSERVATIVE: they only add/refresh framework-owned things and
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
* Migrations are CONSERVATIVE: source rewrites are syntax-aware, idempotent, and
* protected by a complete pre-update backup. User-owned files are never replaced wholesale. Add new ones to `MIGRATIONS`
* as the framework evolves — that is how "new things" reach existing apps.
*/
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
@@ -86,9 +94,95 @@ const LEGACY_VSCODE_EXTENSIONS =
2,
) + "\n";
function walkProjectFiles(dir: string, extension: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue;
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) out.push(...walkProjectFiles(path, extension));
else if (stat.isFile() && path.endsWith(extension)) out.push(path);
}
return out;
}
function formatInlineProps(source: string): string {
return source.replace(
/(^[ \t]*)props\s*\{([^{}\n]*)\}/gm,
(whole, indent: string, body: string) => {
const declaration =
/([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[\]|\{\}|true|false|null|undefined|-?\d+(?:\.\d+)?)/gy;
const values: string[] = [];
let offset = 0;
while (offset < body.length) {
while (/\s/.test(body[offset] ?? "")) offset++;
if (offset >= body.length) break;
declaration.lastIndex = offset;
const match = declaration.exec(body);
if (!match || match.index !== offset) return whole;
values.push(`${match[1]}${match[2] ?? ""} = ${match[3]}`);
offset = declaration.lastIndex;
}
if (values.length < 2) return whole;
return `${indent}props {\n${values.map((value) => `${indent} ${value}`).join("\n")}\n${indent}}`;
},
);
}
function quoteLegacyDynamicAttributes(source: string): string {
let output = "";
let index = 0;
while (index < source.length) {
const start = source.indexOf("<", index);
if (start < 0) return output + source.slice(index);
output += source.slice(index, start);
if (source.startsWith("<!--", start)) {
const end = source.indexOf("-->", start + 4);
if (end < 0) return output + source.slice(start);
output += source.slice(start, end + 3);
index = end + 3;
continue;
}
let end = start + 1;
let quote = "";
let braceDepth = 0;
for (; end < source.length; end++) {
const char = source[end]!;
if (quote) {
if (char === "\\") end++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'") quote = char;
else if (char === "{") braceDepth++;
else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
else if (char === ">" && braceDepth === 0) break;
}
if (end >= source.length) return output + source.slice(start);
let tag = source.slice(start, end + 1);
if (!/^<\/?[A-Za-z]/.test(tag) || /^<\//.test(tag)) {
output += tag;
index = end + 1;
continue;
}
tag = tag.replace(
/(\s[@:#A-Za-z_$][\w$:.-]*)\s*=\s*\{([^{}']+)\}/g,
(_all, name: string, expr: string) => `${name}='{${expr.trim()}}'`,
);
output += tag;
index = end + 1;
}
return output;
}
export function migrateWrnSource(source: string): string {
return formatInlineProps(quoteLegacyDynamicAttributes(source));
}
/**
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
* overwrite user code. Append new entries with the version that ships them.
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
* migrations must preserve semantics and are protected by update backups.
*/
const MIGRATIONS: Migration[] = [
{
@@ -359,7 +453,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.32",
id: "fix-runtime-bug",
id: "fix-runtime-bug-0-2-32",
description:
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
apply() {
@@ -368,7 +462,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.33",
id: "fix-runtime-bug",
id: "fix-runtime-bug-0-2-33",
description:
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
apply() {
@@ -431,7 +525,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.40",
id: "conditions-bug",
id: "conditions-bug-0-2-40",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -440,7 +534,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.41",
id: "conditions-bug",
id: "conditions-bug-0-2-41",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -449,7 +543,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.42",
id: "conditions-bug",
id: "conditions-bug-0-2-42",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -458,7 +552,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.43",
id: "conditions-bug",
id: "conditions-bug-0-2-43",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -467,7 +561,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.44",
id: "conditions-bug",
id: "conditions-bug-0-2-44",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -476,7 +570,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.45",
id: "conditions-bug",
id: "conditions-bug-0-2-45",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -485,7 +579,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.46",
id: "conditions-bug",
id: "conditions-bug-0-2-46",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -494,7 +588,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.47",
id: "conditions-bug",
id: "conditions-bug-0-2-47",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
@@ -503,7 +597,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.48",
id: "components-update",
id: "components-update-0-2-48",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
@@ -511,7 +605,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.49",
id: "components-update",
id: "components-update-0-2-49",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
@@ -519,7 +613,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.50",
id: "components-update",
id: "components-update-0-2-50",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
@@ -769,7 +863,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.78",
id: "ui-components-fix",
id: "ui-components-fix-2",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
@@ -777,12 +871,87 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.2.79",
id: "ui-components-fix",
id: "ui-components-fix-3",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.3.0",
id: "language-foundation-and-compatible-platform-upgrade",
description:
"Adds the shared syntax/AST package, stable diagnostics, plugins, partial hydration, advanced routing, build analysis, tracing, typed data APIs, and safe WRN source normalization.",
apply(ctx) {
const runnable = existsSync(join(ctx.appRoot, "app", "pages"));
const packageFile = join(ctx.appRoot, "package.json");
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
const scripts = (pkg.scripts ??= {});
const dependencies = (pkg.dependencies ??= {});
const addedScripts: string[] = [];
const addedDependencies: string[] = [];
if (runnable) {
for (const [name, command] of Object.entries({
doctor: "wrnexus doctor .",
"config:explain": "wrnexus config . --explain",
analyze: "wrnexus analyze .",
"update:preview": "wrnexus update . --dry-run",
})) {
if (!scripts[name]) {
scripts[name] = command;
addedScripts.push(name);
}
}
for (const name of ["@wrnexus/syntax", "@wrnexus/plugin"]) {
if (!dependencies[name]) {
dependencies[name] = `^${ctx.to}`;
addedDependencies.push(name);
}
}
}
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
if (addedDependencies.length) ctx.log(`+ dependencies: ${addedDependencies.join(", ")}`);
if (!ctx.dryRun && (addedScripts.length || addedDependencies.length)) {
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
}
const changedFiles: string[] = [];
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const after = migrateWrnSource(before);
if (after === before) continue;
changedFiles.push(file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/"));
ctx.log(`~ normalized ${changedFiles[changedFiles.length - 1]}`);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.3.0.json");
ctx.log(`+ .wrnexus/migrations/0.3.0.json (${changedFiles.length} source files normalized)`);
if (!ctx.dryRun) {
mkdirSync(dirname(reportFile), { recursive: true });
writeFileSync(
reportFile,
JSON.stringify(
{
version: "0.3.0",
from: ctx.from,
appliedAt: new Date().toISOString(),
changedFiles,
compatibility: {
legacyDataFor: true,
legacyEachBlocks: true,
legacyComponentMounts: true,
quotedDynamicAttributes: true,
},
},
null,
2,
) + "\n",
"utf8",
);
}
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
@@ -862,12 +1031,13 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
".vscode/extensions.json",
"CLAUDE.md",
"public/llms.txt",
"app",
]) {
const source = join(appRoot, name);
if (existsSync(source)) {
const destination = join(backup, name);
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination);
cpSync(source, destination, { recursive: statSync(source).isDirectory() });
}
}
return backup;
+20
View File
@@ -168,3 +168,23 @@ test("update verification formats before checking and building", () => {
test("update verification skips unavailable scripts", () => {
expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]);
});
test("0.3 WRN source normalization is conservative and idempotent", async () => {
const { migrateWrnSource } = await import("../src/update.ts");
const source = `component Card {
props { eyebrow = "" title = "" description = "" align = "start" class = "" }
view {
<FeatureList items={items} emptyLabel="No items" />
<button @click={save()} data-config={{ nested: true }}>Save</button>
}
}`;
const migrated = migrateWrnSource(source);
expect(migrated).toContain("props {\n");
expect(migrated).toContain(' eyebrow = ""');
expect(migrated).toContain("items='{items}'");
expect(migrated).toContain("@click='{save()}'");
// Nested brace expressions are deliberately left untouched for manual review.
expect(migrated).toContain("data-config={{ nested: true }}");
expect(migrateWrnSource(migrated)).toBe(migrated);
});
+4 -1
View File
@@ -1,9 +1,12 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/syntax": "workspace:*"
}
}
+132 -15
View File
@@ -654,6 +654,32 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<strin
}`;
}
function stableHash(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function hydrationId(ast: PageAst): string {
const shape = JSON.stringify({
kind: ast.kind,
name: ast.name,
props: ast.props.map((entry) => entry.name),
states: ast.states.map((entry) => entry.name),
computed: ast.computed.map((entry) => entry.name),
view: ast.view,
});
return `${ast.name}:${stableHash(shape)}`;
}
function hydrationAttribute(ast: PageAst): string {
const strategy = ast.hydrate ?? "load";
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`;
}
export function generate(ast: PageAst): string {
if (ast.kind === "component" || ast.kind === "layout") {
return generateComponent(ast);
@@ -682,22 +708,46 @@ export function generate(ast: PageAst): string {
// --- 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)};`);
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
if (Object.keys(ast.security).length > 0) {
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
}
// --- View -> default page component ---
const seedScope = evalStateSeeds(ast.states);
for (const entry of ast.computed) {
try {
seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(
seedScope,
);
} catch {
seedScope[entry.name] = undefined;
}
}
const reactiveNames = [
...ast.states.map((entry) => entry.name),
...ast.computed.map((entry) => entry.name),
];
const reactive: PageReactive | null =
ast.states.length > 0
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
: null;
reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), scope: seedScope } : 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);
const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast);
const needsClientRuntime =
ast.runtime !== "server" &&
(ast.states.length > 0 ||
ast.computed.length > 0 ||
hasClientBehavior(ast.view) ||
pageBehavior !== null);
if (needsClientRuntime) {
const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__";
html = `<div data-scope="${scopePlaceholder}">${html}</div>`;
html = `<div data-scope="${scopePlaceholder}"${behaviorAttribute(pageBehavior)}${hydrationAttribute(ast)}>${html}</div>`;
}
if (styles.length > 0) {
@@ -707,6 +757,9 @@ export function generate(ast: PageAst): string {
if (csrBindings.length > 0) {
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
}
if (pageBehavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(pageBehavior, null, 2)};`);
}
// Escape the static HTML for the template literal, then swap loop sentinels for
// their real `${…}` code (which must NOT be escaped).
@@ -805,6 +858,32 @@ export function generate(ast: PageAst): string {
);
}
if (ast.loads.length > 0) {
const serverLoads = ast.loads.filter((entry) => entry.mode === "server");
const clientLoads = ast.loads.filter((entry) => entry.mode === "client");
if (serverLoads.length > 0) {
out.push(
`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`,
);
}
if (clientLoads.length > 0) {
out.push(
`export async function __wrnexusClientLoad(ctx: any) {
${clientLoads.map((entry) => entry.body).join("\n")}
}`,
);
}
}
if (ast.actions.length > 0) {
for (const action of ast.actions) {
out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`);
}
out.push(
`export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`,
);
}
// --- API blocks -> method handlers ---
if (ast.apis.length > 0) {
ast.apis.forEach((api, index) => {
@@ -863,6 +942,8 @@ interface CompCtx {
interface ComponentBehavior {
functions: string;
computed: Array<{ name: string; expr: string }>;
effects: string[];
lifecycle: {
mount?: string;
update?: string;
@@ -874,13 +955,16 @@ interface ComponentBehavior {
}>;
}
/** 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,
);
/** Parse a `data-for="item in list [key expr]"` / `"item, i in list [key expr]"` directive. */
export function parseForExpr(
value: string,
): { item: string; index?: string; list: string; key?: string } | null {
const m =
/^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(
value,
);
if (!m) return null;
return { item: m[1]!, index: m[2], list: m[3]! };
return { item: m[1]!, index: m[2], list: m[3]!.trim(), key: m[4]?.trim() };
}
/** The loop variables a node introduces via `data-for`, if any. */
@@ -953,6 +1037,9 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
.join("\n\n"),
);
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
const lifecycle = {
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
@@ -966,12 +1053,20 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
body: watch.body.trim(),
}));
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
if (
!functions &&
computed.length === 0 &&
effects.length === 0 &&
Object.keys(lifecycle).length === 0 &&
watches.length === 0
) {
return null;
}
return {
functions,
computed,
effects,
lifecycle,
watches,
};
@@ -1357,12 +1452,16 @@ function generateComponent(ast: PageAst): string {
]
: ast.props;
const stateNames = new Set(ast.states.map((s) => s.name));
const stateNames = new Set([
...ast.states.map((entry) => entry.name),
...ast.computed.map((entry) => entry.name),
]);
const nameRefs = new Map<string, string>();
for (const p of effectiveProps) {
nameRefs.set(p.name, safeRef(p.name));
}
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
for (const entry of ast.computed) nameRefs.set(entry.name, safeRef(entry.name));
const resolveExpr = (expr: string): string => {
let result = expr;
for (const [name, ref] of nameRefs) {
@@ -1391,7 +1490,12 @@ function generateComponent(ast: PageAst): string {
// no JavaScript at all.
const behavior = componentBehavior(ast);
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null;
const needsScope =
ast.runtime !== "server" &&
(ast.states.length > 0 ||
ast.computed.length > 0 ||
viewHasEvents(ast.view) ||
behavior !== null);
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
@@ -1418,9 +1522,16 @@ function generateComponent(ast: PageAst): string {
` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`,
);
}
for (const entry of ast.computed) {
decls.push(` const ${nameRefs.get(entry.name)} = (${resolveExpr(entry.expr)});`);
}
const returnExpr = needsScope
? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`"
? "`" +
styleTag +
`<div data-scope="\${__scope}"${behaviorAttr}${hydrationAttribute(ast)}>` +
viewCode +
"</div>`"
: "`" + styleTag + viewCode + "`";
const scopeLine =
@@ -1437,6 +1548,12 @@ function generateComponent(ast: PageAst): string {
} else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
}
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
if (Object.keys(ast.security).length > 0) {
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
}
if (behavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
+63 -35
View File
@@ -1,74 +1,102 @@
/**
* @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.
* Parsing and language diagnostics are provided by the canonical
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
*/
import { parse, ParseError, type PageAst } from "./parser.ts";
import {
assertValidAst,
diagnose,
diagnosticFromError,
formatDiagnostic,
parse,
ParseError,
type PageAst,
type WrnDiagnostic,
} from "@wrnexus/syntax";
import { generate } from "./codegen.ts";
import { generateNative } from "./native-codegen.ts";
export { parse, ParseError } from "./parser.ts";
export {
assertValidAst,
diagnose,
diagnosticFromError,
formatDiagnostic,
parse,
ParseError,
} from "@wrnexus/syntax";
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 { Lexer, LexError } from "@wrnexus/syntax";
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
export type {
PageAst,
SeoBlock,
ViewNode,
Attr,
StateDecl,
PropDecl,
ActionBlock,
ApiBlock,
Attr,
ComputedDecl,
DataApiBlock,
DataMode,
EffectBlock,
LoadBlock,
ModeFunctionsBlock,
PageAst,
PropDecl,
RealtimeBlock,
} from "./parser.ts";
SeoBlock,
StateDecl,
ViewNode,
WrnDiagnostic,
} from "@wrnexus/syntax";
export interface CompileResult {
code: string;
ast: PageAst;
/** Backward-compatible plain diagnostic messages. */
diagnostics: string[];
/** Structured diagnostics for editors, CI, and the DevToolbar. */
richDiagnostics: WrnDiagnostic[];
}
/** Compile `.wrn` source into an Expo Router React Native screen. */
export function compileNativeWireFile(source: string): string {
return generateNative(parse(source));
const ast = parse(source);
assertValidAst(ast);
return generateNative(ast);
}
/**
* Compile `.wrn` source into TypeScript source. Throws `ParseError` on invalid
* input (the dev loader surfaces this as a readable error page).
* Compile `.wrn` source into TypeScript source. Errors include a stable code,
* source location, code frame, and actionable hint whenever available.
*/
export function compileWireFile(source: string, filePath = "<inline .wrn>"): string {
let ast;
try {
ast = parse(source);
const ast = parse(source);
assertValidAst(ast, { file: filePath, accessibility: true });
return `// compiled from .wrn\n${generate(ast)}`;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse ${filePath}: ${message}`, {
const diagnostic = diagnosticFromError(source, error, { file: filePath });
throw new Error(`Failed to parse ${filePath}:\n\n${formatDiagnostic(source, diagnostic)}`, {
cause: error,
});
}
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;
/** Richer entry point returning the AST and structured diagnostics. */
export function compile(source: string, filePath = "<inline .wrn>"): CompileResult {
const richDiagnostics = diagnose(source, { file: filePath, accessibility: true });
const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
if (errors.length > 0) {
throw new ParseError(
errors.map((diagnostic) => diagnostic.message).join("\n"),
errors[0]!.code,
);
}
const ast = parse(source);
return {
code: `// compiled from .wrn\n${generate(ast)}`,
ast,
diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
richDiagnostics,
};
}
+2 -816
View File
@@ -1,816 +1,2 @@
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
*
* Grammar (subset of the vision, but real):
*
* page <Name> {
* 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> } }
* 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";
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;
}
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 type LifecycleHookName = "mount" | "update" | "unmount";
export interface LifecycleBlock {
mount?: string;
update?: string;
unmount?: string;
}
export interface WatchBlock {
state: string;
body: string;
}
export interface RealtimeHandler {
event: string;
args: string[];
body: string;
}
export interface RealtimeBlock {
name: string;
handlers: RealtimeHandler[];
}
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;
}
export interface PageAst {
type: "page";
/** Static ES module imports declared before the WRN root declaration. */
imports: string[];
/**
* `page` is a route, `component` is a reusable fragment,
* and `layout` is a reusable page wrapper.
*/
kind: "page" | "component" | "layout";
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[];
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
types: string[];
states: StateDecl[];
seo: SeoBlock;
view: ViewNode[];
styles: string[];
functions: string[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
lifecycle: LifecycleBlock;
watches: WatchBlock[];
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 imports: string[] = [];
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
while (true) {
while (/\s/u.test(source[lx.pos] ?? "")) lx.pos++;
if (source.startsWith("//", lx.pos)) {
while (lx.pos < source.length && source[lx.pos] !== "\n") lx.pos++;
continue;
}
importPattern.lastIndex = lx.pos;
const statement = importPattern.exec(source);
if (!statement) break;
imports.push(statement[0].trim());
lx.pos = importPattern.lastIndex;
}
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 may contain a page, reusable component, or reusable layout.
const opener = lx.next();
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
throw new ParseError(
`Expected 'page', 'component', or 'layout' but got '${
opener.value || opener.type
}' at offset ${opener.pos}`,
);
}
const kind = opener.value as "page" | "component" | "layout";
const name = expect("ident").value;
expect("lbrace");
let layout: string | undefined;
const props: PropDecl[] = [];
const types: string[] = [];
const states: StateDecl[] = [];
const seo: SeoBlock = {};
const view: ViewNode[] = [];
const styles: string[] = [];
const functions: string[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const lifecycle: LifecycleBlock = {};
const watches: WatchBlock[] = [];
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: Type = <default> } — omit the default for required props.
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;
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.readPropInitializer() : "undefined";
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
}
expect("rbrace");
break;
}
case "state": {
lx.next();
const sName = expect("ident").value;
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": {
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 "lifecycle": {
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const hook = lx.peek();
if (hook.type === "eof") {
throw new ParseError("Unexpected end of input inside lifecycle block");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
}
const hookName = hook.value as LifecycleHookName;
lx.next();
if (lifecycle[hookName] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
}
lifecycle[hookName] = lx.readBalancedBraces();
}
expect("rbrace");
break;
}
case "watch": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body,
});
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");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
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",
imports,
kind,
name,
layout,
props,
types,
states,
seo,
view,
styles,
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
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 isTagNamePart = (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 readTagName = (): string => {
if (i >= src.length || !isNameStart(src[i]!)) {
return fail("Expected a tag name");
}
const start = i++;
while (i < src.length && isTagNamePart(src[i]!)) {
i++;
}
return src.slice(start, i);
};
const readAttributeName = (): string => {
if (i >= src.length) {
return fail("Expected an attribute name");
}
const start = i;
while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (
char === "=" ||
char === ">" ||
char === '"' ||
char === "'" ||
char === " " ||
char === "\t" ||
char === "\n" ||
char === "\r" ||
(char === "/" && next === ">")
) {
break;
}
i++;
}
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i);
};
const parseTag = (): ViewNode => {
i++; // consume '<'
const tag = readTagName();
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 = readAttributeName();
skipWs();
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: true });
continue;
}
const name = readAttributeName();
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 = readTagName();
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 };
}
/** @deprecated Import parser APIs from @wrnexus/syntax. */
export * from "@wrnexus/syntax/parser";
+2 -321
View File
@@ -1,321 +1,2 @@
/**
* 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"
| "colon"
| "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: "colon", 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 a prop default initializer. The initializer may contain nested arrays,
* objects, calls, strings, or template literals. At top level it ends at a
* newline, the closing brace of the props block, or the next inline prop
* declaration (`name = ...` / `name: Type = ...`).
*/
readPropInitializer(): string {
const { src } = this;
while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) {
this.pos++;
}
const start = this.pos;
let square = 0;
let brace = 0;
let paren = 0;
let angle = 0;
let quote: string | null = null;
const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0;
while (this.pos < src.length) {
const c = src[this.pos]!;
if (quote) {
this.pos++;
if (c === "\\" && this.pos < src.length) {
this.pos++;
} else if (c === quote) {
quote = null;
}
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
this.pos++;
continue;
}
if (atTopLevel()) {
if (c === "\n" || c === "\r" || c === "}") break;
if (c === " " || c === "\t") {
let look = this.pos;
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
const rest = src.slice(look);
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) break;
}
}
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--;
else if (c === "<") angle++;
else if (c === ">" && angle > 0) angle--;
this.pos++;
}
const value = src.slice(start, this.pos).trim();
if (!value) throw new LexError(`Expected a prop initializer at offset ${start}`);
return value;
}
/** 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 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
* 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;
}
}
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
export * from "@wrnexus/syntax/tokenizer";
+2 -63
View File
@@ -1,63 +1,2 @@
/** 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}`;
},
);
}
/** @deprecated Import language type utilities from @wrnexus/syntax. */
export * from "@wrnexus/syntax/types";
+42
View File
@@ -996,6 +996,24 @@ component Accordion {
expect(output).not.toContain('${(isOpen(index)) ? " open" : ""}');
});
test("data-for parser supports optional stable key expressions", async () => {
const { parseForExpr } = await import("../src/codegen.ts");
expect(parseForExpr("item, index in items key item.id")).toEqual({
item: "item",
index: "index",
list: "items",
key: "item.id",
});
expect(parseForExpr("item in items")).toEqual({
item: "item",
index: undefined,
list: "items",
key: undefined,
});
});
test("server-rendered each locals work in reactive handlers", async () => {
const firstLocals = Buffer.from(
JSON.stringify({
@@ -1063,3 +1081,27 @@ test("server-rendered each locals work in reactive handlers", async () => {
expect(articles[1]!.querySelector("span")?.classList.contains("open")).toBe(true);
});
test("WRN 0.3 metadata, computed values, loaders, and actions compile additively", () => {
const source = `page Dashboard {
runtime = "universal"
hydrate = "idle"
state count = 2
computed { doubled = count * 2 }
effect { console.log(doubled) }
security { auth = "required" }
load server { return { count: 2 } }
load client { return { refreshed: true } }
action save(input) { return input }
view { <button @click="count++">{doubled}</button> }
}`;
const output = compileWireFile(source);
expect(output).toContain('export const __wrnexusRuntime = "universal"');
expect(output).toContain('export const __wrnexusHydrate = "idle"');
expect(output).toContain("export async function __wrnexusLoad");
expect(output).toContain("export async function __wrnexusClientLoad");
expect(output).toContain("export async function save(input)");
expect(output).toContain("export const __wrnexusActions");
expect(output).toContain('data-wrn-hydrate="idle"');
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+7
View File
@@ -6,6 +6,9 @@
* it can later be reused by the `.wrn` compiler output.
*/
import type { Tenant } from "./tenant.ts";
import type { Tracer } from "./observability.ts";
import {
applyCookieHeaders,
createCookieStore,
@@ -40,6 +43,10 @@ export type Context = {
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
*/
user?: unknown;
/** Active tenant/workspace resolved by tenant middleware. */
tenant?: Tenant;
/** Request tracer installed by observability middleware. */
tracer?: Tracer;
/**
* The direct socket peer IP, set by the server from `server.requestIP`. This
* is NOT spoofable by request headers — prefer it over `x-forwarded-for` for
+56
View File
@@ -0,0 +1,56 @@
import type { Context } from "./context.ts";
export interface CachePolicy {
ttlMs?: number;
staleWhileRevalidateMs?: number;
tags?: string[] | ((ctx: Context) => string[]);
}
export interface LoaderDefinition<T> {
cache?: CachePolicy;
load(ctx: Context): T | Promise<T>;
}
export interface ActionDefinition<I, O> {
csrf?: boolean;
run(input: I, ctx: Context): O | Promise<O>;
invalidate?: string[] | ((output: O, ctx: Context) => string[]);
}
export interface DefinedLoader<T> {
readonly definition: LoaderDefinition<T>;
(ctx: Context): Promise<T>;
}
export interface DefinedAction<I, O> {
readonly definition: ActionDefinition<I, O>;
(input: I, ctx: Context): Promise<O>;
}
export function defineLoader<T>(definition: LoaderDefinition<T>): DefinedLoader<T> {
return Object.assign(async (ctx: Context) => definition.load(ctx), { definition });
}
export function defineAction<I, O>(definition: ActionDefinition<I, O>): DefinedAction<I, O> {
return Object.assign(async (input: I, ctx: Context) => definition.run(input, ctx), {
definition,
});
}
/** Request-local fetch deduplication keyed by a stable string. */
export async function dedupe<T>(ctx: Context, key: string, load: () => T | Promise<T>): Promise<T> {
const bucket = (ctx.locals.__wrnexusData ??= new Map<string, Promise<unknown>>()) as Map<
string,
Promise<unknown>
>;
const existing = bucket.get(key);
if (existing) return existing as Promise<T>;
const pending = Promise.resolve().then(load);
bucket.set(key, pending);
try {
return await pending;
} catch (error) {
bucket.delete(key);
throw error;
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { Context } from "./context.ts";
export interface SchemaLike<T> {
parse(input: unknown): T;
}
export interface EndpointErrorBody {
code: string;
message: string;
details?: unknown;
}
export class EndpointError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "EndpointError";
}
}
export interface EndpointDefinition<I, O> {
input?: SchemaLike<I>;
output?: SchemaLike<O>;
auth?: "optional" | "required";
description?: string;
tags?: string[];
handler(input: I, ctx: Context): O | Promise<O>;
}
export interface DefinedEndpoint<I, O> {
readonly definition: EndpointDefinition<I, O>;
(ctx: Context, input?: unknown): Promise<Response>;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */
export function defineEndpoint<I = unknown, O = unknown>(
definition: EndpointDefinition<I, O>,
): DefinedEndpoint<I, O> {
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
try {
if (definition.auth === "required" && !ctx.user) {
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
}
const input = definition.input ? definition.input.parse(rawInput) : (rawInput as I);
const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? definition.output.parse(rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output });
} catch (error) {
if (error instanceof EndpointError) {
return json(
{ error: { code: error.code, message: error.message, details: error.details } },
error.status,
);
}
return json(
{
error: {
code: "INTERNAL_ERROR",
message: "The endpoint failed unexpectedly.",
} satisfies EndpointErrorBody,
},
500,
);
}
};
return Object.assign(endpoint, { definition });
}
export interface RpcClientOptions {
baseUrl?: string;
fetch?: typeof globalThis.fetch;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
}
/** Create a tiny typed RPC caller for endpoints exposed by a WrNexus app. */
export function createRpcClient(options: RpcClientOptions = {}) {
const request = options.fetch ?? globalThis.fetch;
return async function call<I, O>(path: string, input: I): Promise<O> {
const headers =
typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
const response = await request(new URL(path, options.baseUrl ?? globalThis.location?.origin), {
method: "POST",
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
body: JSON.stringify(input),
});
const body = (await response.json()) as { data?: O; error?: EndpointErrorBody };
if (!response.ok || body.error) {
throw new EndpointError(
response.status,
body.error?.code ?? "RPC_ERROR",
body.error?.message ?? `RPC request failed with ${response.status}`,
body.error?.details,
);
}
return body.data as O;
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { Context } from "./context.ts";
export type FeatureValue = boolean | string | number;
export type FeatureRule = FeatureValue | ((ctx: Context) => FeatureValue | Promise<FeatureValue>);
export interface FeatureFlags {
get(name: string, ctx: Context): Promise<FeatureValue | undefined>;
enabled(name: string, ctx: Context): Promise<boolean>;
}
export function defineFeatureFlags(rules: Record<string, FeatureRule>): FeatureFlags {
return {
async get(name, ctx) {
const rule = rules[name];
return typeof rule === "function" ? rule(ctx) : rule;
},
async enabled(name, ctx) {
return (await this.get(name, ctx)) === true;
},
};
}
+30
View File
@@ -104,3 +104,33 @@ export { setSessionBackend, loadSession } from "./storage.ts";
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
export { defineEndpoint, createRpcClient, EndpointError } from "./endpoint.ts";
export type {
DefinedEndpoint,
EndpointDefinition,
EndpointErrorBody,
RpcClientOptions,
SchemaLike,
} from "./endpoint.ts";
export { defineAction, defineLoader, dedupe } from "./data.ts";
export type {
ActionDefinition,
CachePolicy,
DefinedAction,
DefinedLoader,
LoaderDefinition,
} from "./data.ts";
export { requireTenant, tenantFromSubdomain, tenantMiddleware, tenantScope } from "./tenant.ts";
export type { Tenant, TenantMiddlewareOptions, TenantResolver } from "./tenant.ts";
export { createTracer, tracingMiddleware, withSpan } from "./observability.ts";
export type { Span, SpanRecord, Tracer } from "./observability.ts";
export { defineFeatureFlags } from "./features.ts";
export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
export { checkPerformanceBudgets } from "./performance.ts";
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
+109
View File
@@ -0,0 +1,109 @@
import type { Context, Middleware } from "./context.ts";
export interface SpanRecord {
name: string;
startTime: number;
endTime?: number;
durationMs?: number;
status?: "ok" | "error";
attributes: Record<string, string | number | boolean>;
error?: unknown;
}
export interface Tracer {
startSpan(name: string, attributes?: SpanRecord["attributes"]): Span;
records(): readonly SpanRecord[];
}
export interface Span {
setAttribute(name: string, value: string | number | boolean): void;
end(status?: "ok" | "error", error?: unknown): SpanRecord;
}
export function createTracer(clock: () => number = () => performance.now()): Tracer {
const spans: SpanRecord[] = [];
return {
startSpan(name, attributes = {}) {
const record: SpanRecord = { name, startTime: clock(), attributes: { ...attributes } };
spans.push(record);
let ended = false;
return {
setAttribute(key, value) {
record.attributes[key] = value;
},
end(status = "ok", error) {
if (!ended) {
ended = true;
record.endTime = clock();
record.durationMs = record.endTime - record.startTime;
record.status = status;
record.error = error;
}
return record;
},
};
},
records: () => spans,
};
}
export async function withSpan<T>(
tracer: Tracer,
name: string,
run: (span: Span) => T | Promise<T>,
attributes?: SpanRecord["attributes"],
): Promise<T> {
const span = tracer.startSpan(name, attributes);
try {
const result = await run(span);
span.end("ok");
return result;
} catch (error) {
span.end("error", error);
throw error;
}
}
export interface TracingMiddlewareOptions {
/** Include W3C Server-Timing response headers. Defaults to true. */
serverTiming?: boolean;
/** Fraction of requests to trace, from 0 to 1. Defaults to 1. */
sampleRate?: number;
/** Called after a traced response completes. */
onComplete?: (ctx: Context, records: readonly SpanRecord[]) => void | Promise<void>;
}
export function tracingMiddleware(
tracerFactory: (ctx: Context) => Tracer = () => createTracer(),
options: TracingMiddlewareOptions = {},
): Middleware {
const sampleRate = Math.max(0, Math.min(1, options.sampleRate ?? 1));
return async (ctx, next) => {
if (sampleRate === 0 || (sampleRate < 1 && Math.random() > sampleRate)) return next();
const tracer = tracerFactory(ctx);
ctx.tracer = tracer;
const response = await withSpan(tracer, "http.request", () => next(), {
method: ctx.req.method,
path: ctx.url.pathname,
});
const records = tracer.records();
await options.onComplete?.(ctx, records);
if (options.serverTiming === false) return response;
const headers = new Headers(response.headers);
const timings = records
.filter((record) => record.durationMs !== undefined)
.map(
(record, index) =>
`wrn${index};dur=${record.durationMs!.toFixed(2)};desc="${record.name.replace(/"/g, "")}"`,
);
if (timings.length) headers.set("server-timing", timings.join(", "));
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
}
+38
View File
@@ -0,0 +1,38 @@
export interface PerformanceBudgets {
routeJsBytes?: number;
routeCssBytes?: number;
htmlBytes?: number;
imageBytes?: number;
hydrationMs?: number;
serverRenderMs?: number;
}
export interface PerformanceMeasurement {
routeJsBytes?: number;
routeCssBytes?: number;
htmlBytes?: number;
imageBytes?: number;
hydrationMs?: number;
serverRenderMs?: number;
}
export interface BudgetViolation {
metric: keyof PerformanceBudgets;
budget: number;
actual: number;
overBy: number;
}
export function checkPerformanceBudgets(
budgets: PerformanceBudgets,
measurement: PerformanceMeasurement,
): BudgetViolation[] {
const violations: BudgetViolation[] = [];
for (const metric of Object.keys(budgets) as Array<keyof PerformanceBudgets>) {
const budget = budgets[metric];
const actual = measurement[metric];
if (budget === undefined || actual === undefined || actual <= budget) continue;
violations.push({ metric, budget, actual, overBy: actual - budget });
}
return violations;
}
+56
View File
@@ -0,0 +1,56 @@
import type { Context, Middleware } from "./context.ts";
export interface Tenant {
id: string;
slug?: string;
name?: string;
metadata?: Record<string, unknown>;
}
export type TenantResolver = (ctx: Context) => Tenant | null | Promise<Tenant | null>;
export interface TenantMiddlewareOptions {
required?: boolean;
status?: number;
}
export function tenantMiddleware(
resolveTenant: TenantResolver,
options: TenantMiddlewareOptions = {},
): Middleware {
return async (ctx, next) => {
const tenant = await resolveTenant(ctx);
ctx.tenant = tenant ?? undefined;
if (!tenant && options.required !== false) {
return new Response("Tenant not found", { status: options.status ?? 404 });
}
return next();
};
}
export function tenantFromSubdomain(
lookup: (slug: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
rootDomains: string[] = [],
): TenantResolver {
return async (ctx) => {
const host = ctx.url.hostname.toLowerCase();
const root = rootDomains.find((domain) => host === domain || host.endsWith(`.${domain}`));
const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0];
if (!slug || slug === host || slug === "www") return null;
return lookup(slug, ctx);
};
}
export function requireTenant(ctx: Context): Tenant {
if (!ctx.tenant)
throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant.");
return ctx.tenant;
}
/** Wrap a repository so every operation receives the current tenant id. */
export function tenantScope<T extends object>(
tenant: Tenant,
repository: T,
): T & { tenantId: string } {
return Object.assign(Object.create(repository), { tenantId: tenant.id });
}
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test";
import {
checkPerformanceBudgets,
createContext,
createTracer,
dedupe,
defineAction,
defineEndpoint,
defineFeatureFlags,
defineLoader,
tenantFromSubdomain,
tracingMiddleware,
} from "../src/index.ts";
function context(url = "https://acme.example.com/dashboard") {
return createContext(new Request(url), new URL(url));
}
test("typed endpoints validate authentication and preserve a stable JSON envelope", async () => {
const endpoint = defineEndpoint<{ value: number }, { doubled: number }>({
auth: "required",
input: {
parse(input) {
const value = Number((input as { value?: unknown })?.value);
if (!Number.isFinite(value)) throw new Error("invalid");
return { value };
},
},
handler: ({ value }) => ({ doubled: value * 2 }),
});
expect((await endpoint(context(), { value: 4 })).status).toBe(401);
const authenticated = context();
authenticated.user = { id: "user-1" };
expect(await (await endpoint(authenticated, { value: 4 })).json()).toEqual({
data: { doubled: 8 },
});
});
test("loaders, actions, and request-local dedupe remain framework-agnostic", async () => {
let calls = 0;
const loader = defineLoader({ load: async () => ({ ready: true }) });
const action = defineAction<{ name: string }, string>({ run: async (input) => input.name });
const ctx = context();
const first = dedupe(ctx, "profile", async () => ++calls);
const second = dedupe(ctx, "profile", async () => ++calls);
expect(await loader(ctx)).toEqual({ ready: true });
expect(await action({ name: "Ajay" }, ctx)).toBe("Ajay");
expect(await Promise.all([first, second])).toEqual([1, 1]);
expect(calls).toBe(1);
});
test("feature flags, tenant resolution, budgets, and tracing compose", async () => {
const ctx = context();
const resolveTenant = tenantFromSubdomain(async (slug) => ({ id: slug, slug }), ["example.com"]);
expect(await resolveTenant(ctx)).toEqual({ id: "acme", slug: "acme" });
const flags = defineFeatureFlags({ dashboardV2: true, seats: 25 });
expect(await flags.enabled("dashboardV2", ctx)).toBe(true);
expect(await flags.get("seats", ctx)).toBe(25);
expect(checkPerformanceBudgets({ routeJsBytes: 100 }, { routeJsBytes: 130 })).toEqual([
{ metric: "routeJsBytes", budget: 100, actual: 130, overBy: 30 },
]);
const tracer = createTracer(() => 10);
const middleware = tracingMiddleware(() => tracer, { serverTiming: true });
const response = await middleware(ctx, () => new Response("ok"));
expect(response.headers.get("server-timing")).toContain("http.request");
});
+10 -9
View File
@@ -49,15 +49,16 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
| Directive | Purpose |
| -------------------------------------------------- | ----------------------------------------------------------------------- |
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
| `data-text="expr"` | Bind an element's `textContent` to an expression |
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template |
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
| Directive | Purpose |
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
| `data-text="expr"` | Bind an element's `textContent` to an expression |
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+387 -108
View File
@@ -25,6 +25,21 @@ export const REACTIVE_RUNTIME = String.raw`
var updateHooksScheduled = false;
var behaviorObserver;
function reportDiagnostic(code, message, element, detail) {
var payload = {
code: code,
message: message,
hydrationId: element && element.getAttribute ? element.getAttribute("data-wrn-hydration") : null,
detail: detail || null,
};
console.error("[wrnexus:" + code + "] " + message, detail || "");
try {
window.dispatchEvent(new CustomEvent("wrnexus:diagnostic", { detail: payload }));
} catch (_) {
// CustomEvent can be unavailable in minimal DOM test environments.
}
}
function scheduleUpdateHook(element, callback) {
pendingUpdateHooks.set(element, callback);
if (updateHooksScheduled) return;
@@ -424,8 +439,10 @@ export const REACTIVE_RUNTIME = String.raw`
encodedScope,
);
} catch (error) {
console.error(
"[wrnexus] failed to decode scope payload",
reportDiagnostic(
"WRN-HYDRATE-SCOPE",
"Failed to decode the server-rendered scope payload.",
el,
error,
);
@@ -445,6 +462,16 @@ export const REACTIVE_RUNTIME = String.raw`
var signals = {};
Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); });
var behavior = parseBehavior(el);
var computedDefinitions = {};
var computing = new Set();
if (behavior && Array.isArray(behavior.computed)) {
behavior.computed.forEach(function (entry) {
if (entry && typeof entry.name === "string" && typeof entry.expr === "string") {
computedDefinitions[entry.name] = entry.expr;
}
});
}
var renderers = [];
// Dependency-tracked rendering: while a renderer runs, every signal it reads
@@ -452,7 +479,41 @@ export const REACTIVE_RUNTIME = String.raw`
// change then re-runs only the renderers that actually read it. The Set in
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
var currentRenderer = null;
var currentRenderer = null;
var pendingRenderers = new Set();
var batchDepth = 0;
var flushingRenderers = false;
function flushRenderers() {
if (flushingRenderers || batchDepth > 0) return;
flushingRenderers = true;
try {
while (pendingRenderers.size > 0) {
var queue = Array.from(pendingRenderers);
pendingRenderers.clear();
queue.forEach(function (run) { run(); });
}
} finally {
flushingRenderers = false;
}
}
function scheduleRenderer(renderer) {
pendingRenderers.add(renderer);
flushRenderers();
}
function batchUpdates(callback) {
batchDepth++;
try {
return callback();
} finally {
batchDepth--;
flushRenderers();
}
}
function reactive(fn) {
var running = false;
@@ -467,7 +528,7 @@ export const REACTIVE_RUNTIME = String.raw`
var previousRenderer =
currentRenderer;
currentRenderer = run;
currentRenderer = schedule;
try {
fn();
@@ -479,6 +540,10 @@ export const REACTIVE_RUNTIME = String.raw`
}
}
function schedule() {
scheduleRenderer(run);
}
renderers.push(run);
return run;
@@ -577,6 +642,18 @@ export const REACTIVE_RUNTIME = String.raw`
}
function readScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
if (computing.has(name)) {
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
return undefined;
}
computing.add(name);
try {
return evalExpr(computedDefinitions[name]);
} finally {
computing.delete(name);
}
}
var sig = signals[name];
if (sig) {
if (currentRenderer) sig.subscribe(currentRenderer);
@@ -589,6 +666,9 @@ export const REACTIVE_RUNTIME = String.raw`
}
function peekScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
return readScope(name);
}
if (signals[name]) return signals[name].get();
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
return behaviorFunctions[name];
@@ -664,68 +744,70 @@ export const REACTIVE_RUNTIME = String.raw`
source,
locals,
) {
var statements =
splitStatements(source);
return batchUpdates(function () {
var statements =
splitStatements(source);
for (
var statementIndex = 0;
statementIndex <
statements.length;
statementIndex++
) {
var result = runStatement(
statements[statementIndex],
function (expression) {
return evalExpr(
expression,
locals,
);
},
function (name) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
locals,
name,
)
) {
return locals[name];
}
for (
var statementIndex = 0;
statementIndex <
statements.length;
statementIndex++
) {
var result = runStatement(
statements[statementIndex],
function (expression) {
return evalExpr(
expression,
locals,
);
},
function (name) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
locals,
name,
)
) {
return locals[name];
}
return peekScope(name);
},
function (name, value) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
locals,
name,
)
) {
locals[name] = value;
} else {
writeScope(name, value);
}
},
function (body) {
return runStmt(
body,
locals,
);
},
);
return peekScope(name);
},
function (name, value) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
locals,
name,
)
) {
locals[name] = value;
} else {
writeScope(name, value);
}
},
function (body) {
return runStmt(
body,
locals,
);
},
);
if (result.returned) {
return result;
if (result.returned) {
return result;
}
}
}
return {
returned: false,
value: undefined,
};
return {
returned: false,
value: undefined,
};
});
}
function installBehaviorFunctions(source) {
@@ -771,13 +853,31 @@ export const REACTIVE_RUNTIME = String.raw`
}
// --- data-for list rendering -------------------------------------------
// Each [data-for="item in list"] element is a per-item template. On any
// change to the list (or a dependency an item reads), the list re-renders.
// Each [data-for="item in list"] element is a per-item template. Add
// data-key="item.id" or a key item.id suffix preserves DOM nodes when
// a list is reordered. Unkeyed loops retain the legacy full-rerender path.
function parseFor(value) {
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(
value || "",
);
return m ? { item: m[1], index: m[2], list: m[3] } : null;
return m ? { item: m[1], index: m[2], list: m[3].trim(), key: m[4] && m[4].trim() } : null;
}
function unwrapForKey(value) {
var expression = String(value || "").trim();
if (expression.charAt(0) === "{" && expression.charAt(expression.length - 1) === "}") {
expression = expression.slice(1, -1).trim();
}
return expression;
}
function stableForKey(value) {
if (value === null) return "null:";
var type = typeof value;
if (type === "object") {
try { return "object:" + JSON.stringify(value); } catch (_) { return "object:" + String(value); }
}
return type + ":" + String(value);
}
function fillMustache(str, itemEval) {
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
@@ -1152,6 +1252,18 @@ export const REACTIVE_RUNTIME = String.raw`
"data-for",
);
var keyExpression =
spec.key ||
unwrapForKey(
tpl.getAttribute(
"data-key",
),
);
template.removeAttribute(
"data-key",
);
var parent =
tpl.parentNode;
@@ -1168,6 +1280,7 @@ export const REACTIVE_RUNTIME = String.raw`
parent.removeChild(tpl);
var clones = [];
var keyedRecords = new Map();
reactive(function () {
var list =
@@ -1184,64 +1297,155 @@ export const REACTIVE_RUNTIME = String.raw`
list = [];
}
for (
var cloneIndex = 0;
cloneIndex <
clones.length;
cloneIndex++
) {
var existingClone =
clones[cloneIndex];
if (
existingClone.parentNode
if (!keyExpression) {
for (
var cloneIndex = 0;
cloneIndex <
clones.length;
cloneIndex++
) {
existingClone.parentNode
.removeChild(
existingClone,
);
var existingClone =
clones[cloneIndex];
if (
existingClone.parentNode
) {
existingClone.parentNode
.removeChild(
existingClone,
);
}
}
clones = [];
var fragment =
document.createDocumentFragment();
for (
var itemIndex = 0;
itemIndex < list.length;
itemIndex++
) {
var clone =
template.cloneNode(true);
var locals = {};
locals[spec.item] =
list[itemIndex];
if (spec.index) {
locals[spec.index] =
itemIndex;
}
hydrateItem(
clone,
locals,
);
fragment.appendChild(
clone,
);
clones.push(clone);
}
parent.insertBefore(
fragment,
marker.nextSibling,
);
return;
}
clones = [];
var fragment =
document.createDocumentFragment();
var nextRecords = new Map();
var orderedNodes = [];
for (
var itemIndex = 0;
itemIndex < list.length;
itemIndex++
var keyedIndex = 0;
keyedIndex < list.length;
keyedIndex++
) {
var clone =
template.cloneNode(true);
var keyedItem = list[keyedIndex];
var keyedLocals = {};
var locals = {};
keyedLocals[spec.item] = keyedItem;
if (spec.index) keyedLocals[spec.index] = keyedIndex;
locals[spec.item] =
list[itemIndex];
if (spec.index) {
locals[spec.index] =
itemIndex;
var rawKey;
try {
rawKey = evaluateExpression(
keyExpression,
function (name) {
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
? keyedLocals[name]
: readScope(name);
},
);
} catch (error) {
reportDiagnostic(
"WRN-HYDRATE-KEY-001",
"Unable to evaluate data-for key '" + keyExpression + "'.",
el,
error,
);
rawKey = keyedIndex;
}
hydrateItem(
clone,
locals,
);
var normalizedKey = stableForKey(rawKey);
if (nextRecords.has(normalizedKey)) {
reportDiagnostic(
"WRN-HYDRATE-KEY-002",
"Duplicate data-for key '" + String(rawKey) + "'; falling back to its index.",
el,
{ key: rawKey, index: keyedIndex },
);
normalizedKey += ":index:" + keyedIndex;
}
fragment.appendChild(
clone,
);
var record = keyedRecords.get(normalizedKey);
if (
!record ||
record.item !== keyedItem ||
(spec.index && record.index !== keyedIndex)
) {
if (record && record.node.parentNode) {
record.node.parentNode.removeChild(record.node);
}
clones.push(clone);
var keyedClone = template.cloneNode(true);
hydrateItem(keyedClone, keyedLocals);
record = {
node: keyedClone,
item: keyedItem,
index: keyedIndex,
};
}
nextRecords.set(normalizedKey, record);
orderedNodes.push(record.node);
}
keyedRecords.forEach(function (record, key) {
if (!nextRecords.has(key) && record.node.parentNode) {
record.node.parentNode.removeChild(record.node);
}
});
var keyedFragment = document.createDocumentFragment();
orderedNodes.forEach(function (node) {
keyedFragment.appendChild(node);
});
parent.insertBefore(
fragment,
keyedFragment,
marker.nextSibling,
);
keyedRecords = nextRecords;
clones = orderedNodes;
});
});
@@ -1512,10 +1716,18 @@ export const REACTIVE_RUNTIME = String.raw`
});
});
var behavior = parseBehavior(el);
if (behavior) {
installBehaviorFunctions(behavior.functions);
(behavior.effects || []).forEach(function (source) {
if (typeof source !== "string" || !source.trim()) return;
reactive(function () {
try { runStmt(source); } catch (error) {
reportDiagnostic("WRN-EFFECT-ERROR", "Reactive effect failed.", el, error);
}
});
});
(behavior.watches || []).forEach(function (watch) {
if (!watch || typeof watch.state !== "string" || typeof watch.body !== "string") return;
if (!stateWatchers[watch.state]) stateWatchers[watch.state] = [];
@@ -1609,6 +1821,73 @@ export const REACTIVE_RUNTIME = String.raw`
behaviorObserver.observe(document.documentElement, { childList: true, subtree: true });
}
function queueScopeHydration(element) {
if (!element || element.__wrnexusScope || element.__wrnexusHydrationQueued) return;
var runtime = element.getAttribute("data-wrn-runtime") || "universal";
var strategy = element.getAttribute("data-wrn-hydrate") || "load";
if (runtime === "server" || strategy === "none") return;
element.__wrnexusHydrationQueued = true;
function hydrate() {
if (element.__wrnexusScope || !element.isConnected) return;
setupScope(element);
}
if (strategy === "load") {
hydrate();
return;
}
if (strategy === "idle") {
var idle = window.requestIdleCallback || function (callback) { return window.setTimeout(callback, 1); };
idle(hydrate);
return;
}
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
var observer = new IntersectionObserver(function (entries) {
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
observer.disconnect();
hydrate();
});
observer.observe(element);
return;
}
if (strategy === "interaction") {
var activate = function () {
element.removeEventListener("pointerdown", activate, true);
element.removeEventListener("keydown", activate, true);
element.removeEventListener("focusin", activate, true);
hydrate();
};
element.addEventListener("pointerdown", activate, true);
element.addEventListener("keydown", activate, true);
element.addEventListener("focusin", activate, true);
return;
}
if (strategy.indexOf("media:") === 0 && typeof window.matchMedia === "function") {
var query = strategy.slice(6);
var media = window.matchMedia(query);
if (media.matches) hydrate();
else {
var onChange = function (event) {
if (!event.matches) return;
if (media.removeEventListener) media.removeEventListener("change", onChange);
else media.removeListener(onChange);
hydrate();
};
if (media.addEventListener) media.addEventListener("change", onChange);
else media.addListener(onChange);
}
return;
}
reportDiagnostic(
"WRN-HYDRATE-STRATEGY",
"Unknown hydration strategy '" + strategy + "'. Falling back to load.",
element,
);
hydrate();
}
function hydrateScopes(root) {
var host = root || document;
@@ -1620,13 +1899,13 @@ export const REACTIVE_RUNTIME = String.raw`
host.matches &&
host.matches(scopeSelector)
) {
setupScope(host);
queueScopeHydration(host);
}
if (host.querySelectorAll) {
host
.querySelectorAll(scopeSelector)
.forEach(setupScope);
.forEach(queueScopeHydration);
}
ensureBehaviorObserver();
+19
View File
@@ -99,6 +99,25 @@ test("data-for exposes item + index, mustaches and member access", () => {
]);
});
test("keyed data-for preserves DOM identity when items reorder", () => {
const win = mount(
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
</div>`,
);
const before = Array.from(win.document.querySelectorAll("li"));
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
(win.document.querySelector("button") as unknown as HTMLElement).click();
const after = Array.from(win.document.querySelectorAll("li"));
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
expect(after[0]).toBe(before[1]);
expect(after[1]).toBe(before[0]);
});
test("expression evaluator: member access, ternary, comparison, calls", () => {
const win = mount(
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -20,6 +20,7 @@
"@wrnexus/i18n": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/pubsub": "workspace:*",
"@wrnexus/uploader": "workspace:*"
"@wrnexus/uploader": "workspace:*",
"@wrnexus/plugin": "workspace:*"
}
}
+33 -1
View File
@@ -32,6 +32,8 @@ export { RESTART_EXIT_CODE } from "./restart.ts";
import { resetDevCache } from "./cache.ts";
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
import { createPluginRunner, type PluginInput } from "@wrnexus/plugin";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
@@ -67,6 +69,9 @@ export interface ServeOptions {
mobile?: MobileConfig;
pwa?: PwaConfig | false;
devToolbar?: boolean | DevToolbarConfig;
plugins?: PluginInput;
observability?: ObservabilityConfig;
tenancy?: TenancyConfig;
}
export interface RunningServer {
@@ -153,7 +158,20 @@ function resolveDevToolbarConfig(
export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const appDir = resolve(opts.appDir);
const appRoot = dirname(appDir);
const mode: Mode = opts.mode ?? "development";
const pluginRunner = createPluginRunner(opts.plugins, {
root: appRoot,
mode,
command: "dev",
metadata: new Map(),
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
await pluginRunner.configure(opts as unknown as Record<string, unknown>);
await pluginRunner.configResolved(
Object.freeze({ ...opts }) as Readonly<Record<string, unknown>>,
);
const hmr = opts.hmr ?? mode === "development";
const port = opts.port ?? 3000;
const hostname = opts.hostname ?? "::";
@@ -161,7 +179,6 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
const styleEntry = opts.styleEntry ?? null;
const appRoot = dirname(appDir);
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
@@ -256,6 +273,8 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
devToolbar:
@@ -278,6 +297,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
websocket: handlers.websocket,
});
try {
await pluginRunner.hook("configureServer", {
server,
router,
handlers,
assets,
devToolbarCollector,
});
} catch (error) {
server.stop();
throw error;
}
let watcher: ReturnType<typeof startWatcher>;
// In-process HMR: keep the server and socket alive, invalidate only changed
+8
View File
@@ -23,6 +23,8 @@ import {
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
@@ -116,6 +118,10 @@ export interface ProdOptions {
pwa?: PwaConfig | false;
/** Framework security headers and CORS policy. */
security?: SecurityConfig;
/** Built-in request tracing and Server-Timing policy. */
observability?: ObservabilityConfig;
/** Built-in tenant identity resolution. */
tenancy?: TenancyConfig;
port?: number;
hostname?: string;
maxBodyBytes?: number;
@@ -305,6 +311,8 @@ export function createProductionHandlers(
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
maxBodyBytes: opts.maxBodyBytes,
realtimeBus: realtimeBusFromConfig(opts.realtime),
});
+74 -2
View File
@@ -23,6 +23,8 @@ import {
withContextHeaders,
withSecurityHeaders,
resolveRequestUrl,
tenantMiddleware,
tracingMiddleware,
type Context,
type Middleware,
type Mode,
@@ -43,6 +45,8 @@ import {
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
} from "@wrnexus/styles";
import {
LANG_COOKIE,
@@ -129,6 +133,10 @@ export interface RuntimeDeps {
pwa?: PwaConfig | false;
/** Framework security headers and CORS policy. */
security?: SecurityConfig;
/** Built-in request tracing and Server-Timing policy. */
observability?: ObservabilityConfig;
/** Built-in tenant identity resolution. */
tenancy?: TenancyConfig;
/** Max request body size in bytes (413 above this). Default 10 MB. */
maxBodyBytes?: number;
/** HMR hub for browser live-update sockets (dev only). */
@@ -158,6 +166,65 @@ function shouldEnableDevToolbar(mode: string, deps: RuntimeDeps): boolean {
);
}
function tenantIdentityFromConfig(
config: TenancyConfig,
): (ctx: Context) => Promise<{ id: string; slug?: string } | null> {
return async (ctx) => {
const host = ctx.url.hostname.toLowerCase();
if (config.mode === "domain") return host ? { id: host, slug: host } : null;
if (config.mode === "path") {
const segments = ctx.url.pathname.split("/").filter(Boolean);
const prefix = config.pathPrefix?.replace(/^\/+|\/+$/g, "");
const slug = prefix ? (segments[0] === prefix ? segments[1] : undefined) : segments[0];
return slug ? { id: slug, slug } : null;
}
if (config.mode === "subdomain" || config.mode === undefined) {
const roots = config.rootDomains?.map((domain) => domain.toLowerCase()) ?? [];
const root = roots.find((domain) => host === domain || host.endsWith(`.${domain}`));
const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0];
if (!slug || slug === host || slug === "www" || slug === "localhost") return null;
return { id: slug, slug };
}
return null;
};
}
function frameworkMiddleware(deps: RuntimeDeps): Middleware[] {
const middleware: Middleware[] = [];
if (deps.observability && deps.observability.enabled !== false) {
middleware.push(
tracingMiddleware(undefined, {
sampleRate: deps.observability.sampleRate,
serverTiming: deps.observability.serverTiming,
onComplete:
deps.observability.exporter === "console"
? (ctx, records) => {
const total = records.find((record) => record.name === "http.request")?.durationMs;
console.log(
`[wrnexus:trace] ${ctx.req.method} ${ctx.url.pathname} ${total?.toFixed(2) ?? "0.00"}ms`,
);
}
: undefined,
}),
);
}
if (deps.tenancy && deps.tenancy.mode !== "custom") {
middleware.push(
tenantMiddleware(tenantIdentityFromConfig(deps.tenancy), {
required: deps.tenancy.required,
}),
);
}
return middleware;
}
function shouldReportNotFound(pathname: string): boolean {
return !(
pathname.startsWith("/__wrnexus/") ||
@@ -597,6 +664,11 @@ export interface Handlers {
/** Build the fetch + websocket handlers from a set of dependencies. */
export function createHandlers(deps: RuntimeDeps): Handlers {
const { mode, hmr, router, loadModule, getMiddleware, assets } = deps;
const builtInMiddleware = frameworkMiddleware(deps);
const resolveMiddleware = async (): Promise<Middleware[]> => [
...builtInMiddleware,
...(await getMiddleware()),
];
const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default
// Server-side realtime room manager (shared by every `defineRoom` connection).
@@ -832,7 +904,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
);
ctx.t = makeT(deps.i18n, ctx.lang);
}
const mws = await getMiddleware();
const mws = await resolveMiddleware();
const res = secure(
withContextHeaders(ctx, await runMiddleware(mws, ctx, () => dispatch(ctx))),
);
@@ -1271,7 +1343,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}
const res = withContextHeaders(
ctx,
await runMiddleware(await getMiddleware(), ctx, () => dispatch(ctx)),
await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)),
);
const html = await res.text();
ws.send(JSON.stringify({ type: "html", html }));
+3
View File
@@ -43,6 +43,9 @@ const server = await startServer({
mobile: config.mobile,
pwa: config.pwa,
devToolbar: config.devToolbar,
plugins: config.plugins,
observability: config.observability,
tenancy: config.tenancy,
});
const r = server.router;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"sideEffects": false,
@@ -50,6 +50,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
root.addEventListener("click",event=>{const button=event.target.closest("button");if(!button)return;if(button.matches("[data-toggle]")){state.open=!state.open;state.severity="all";panel.classList.toggle("open",state.open);render()}if(button.matches("[data-close]")){state.open=false;panel.classList.remove("open")}if(button.matches("[data-scan]"))scan();if(button.matches("[data-clear]")){state.issues=[];state.severity="all";state.search="";search.value="";render()}if(button.dataset.filter){state.severity=state.severity===button.dataset.filter?"all":button.dataset.filter;state.open=true;panel.classList.add("open");render()}if(button.dataset.highlight)highlight(button.dataset.highlight)});
search.addEventListener("input",()=>{state.search=search.value.toLowerCase();render()});
addEventListener("wrnexus:navigated",()=>{if(state.config.scanOnNavigation)setTimeout(scan,50)});addEventListener("wrnexus:hmr",()=>{if(state.config.scanOnHmr)setTimeout(scan,100)});addEventListener("wrnexus:runtime-error",event=>addRuntime("error","WRNexus runtime error",event.detail?.message||"Runtime failure",event.detail||{}));
addEventListener("wrnexus:diagnostic",event=>{const detail=event.detail||{};const code=detail.code||"WRN-RUNTIME";const category=String(code).includes("HYDRATE")?"runtime":String(code).includes("ROUTE")?"routing":"compiler";const title=String(code).includes("HYDRATE")?"Hydration diagnostic":"WRNexus diagnostic";const x=issue(String(code),category,"error",title,detail.message||"Framework diagnostic",null,"Open the source location and resolve the reported framework contract.","high",detail);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();});
window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}};
const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]});
setTimeout(scan,100);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+7
View File
@@ -0,0 +1,7 @@
# @wrnexus/plugin
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
diagnostics, development servers, production builds, and DevToolbar extensions.
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
Duplicate names and dependency cycles are rejected.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@wrnexus/plugin",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/syntax": "workspace:*"
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
export type PluginOrder = "pre" | "normal" | "post";
export interface PluginContext {
root: string;
mode: "development" | "production";
command: "dev" | "build" | "test";
profile?: string;
metadata: Map<string, unknown>;
warn(message: string): void;
}
export interface TransformContext extends PluginContext {
file: string;
}
export interface WrnexusPlugin {
name: string;
version?: string;
enforce?: PluginOrder;
/** Plugin names that must execute first. */
after?: string[];
/** Plugin names that must execute later. */
before?: string[];
configure?(config: Record<string, unknown>, context: PluginContext): void | Promise<void>;
configResolved?(
config: Readonly<Record<string, unknown>>,
context: PluginContext,
): void | Promise<void>;
transformAst?(ast: PageAst, context: TransformContext): PageAst | void | Promise<PageAst | void>;
transformCode?(code: string, context: TransformContext): string | void | Promise<string | void>;
diagnostics?(ast: PageAst, context: TransformContext): WrnDiagnostic[] | Promise<WrnDiagnostic[]>;
routes?(routes: unknown[], context: PluginContext): unknown[] | void | Promise<unknown[] | void>;
configureServer?(server: unknown, context: PluginContext): void | Promise<void>;
buildStart?(context: PluginContext): void | Promise<void>;
buildEnd?(result: unknown, context: PluginContext): void | Promise<void>;
devToolbarPanels?(context: PluginContext): unknown[] | Promise<unknown[]>;
}
export type PluginInput = WrnexusPlugin | false | null | undefined | PluginInput[];
export function definePlugin(plugin: WrnexusPlugin): WrnexusPlugin {
if (!plugin.name || !/^[a-z0-9@][a-z0-9@/._-]*$/i.test(plugin.name)) {
throw new Error("WRN-PLUGIN-NAME: plugins require a stable package-style name.");
}
return plugin;
}
function flatten(input: PluginInput, output: WrnexusPlugin[]): void {
if (!input) return;
if (Array.isArray(input)) {
for (const entry of input) flatten(entry, output);
} else {
output.push(definePlugin(input));
}
}
function rank(plugin: WrnexusPlugin): number {
return plugin.enforce === "pre" ? 0 : plugin.enforce === "post" ? 2 : 1;
}
/** Resolve plugin order deterministically and reject duplicates/cycles. */
export function resolvePlugins(input: PluginInput): WrnexusPlugin[] {
const plugins: WrnexusPlugin[] = [];
flatten(input, plugins);
const byName = new Map<string, WrnexusPlugin>();
for (const plugin of plugins) {
if (byName.has(plugin.name)) throw new Error(`WRN-PLUGIN-DUPLICATE: ${plugin.name}`);
byName.set(plugin.name, plugin);
}
const edges = new Map<string, Set<string>>();
for (const plugin of plugins) edges.set(plugin.name, new Set());
for (const plugin of plugins) {
for (const dependency of plugin.after ?? []) {
if (byName.has(dependency)) edges.get(dependency)!.add(plugin.name);
}
for (const dependent of plugin.before ?? []) {
if (byName.has(dependent)) edges.get(plugin.name)!.add(dependent);
}
}
for (const left of plugins) {
for (const right of plugins) {
if (rank(left) < rank(right)) edges.get(left.name)!.add(right.name);
}
}
const indegree = new Map(plugins.map((plugin) => [plugin.name, 0]));
for (const targets of edges.values()) {
for (const target of targets) indegree.set(target, (indegree.get(target) ?? 0) + 1);
}
const ready = plugins
.filter((plugin) => indegree.get(plugin.name) === 0)
.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
const resolved: WrnexusPlugin[] = [];
while (ready.length) {
const plugin = ready.shift()!;
resolved.push(plugin);
for (const target of edges.get(plugin.name) ?? []) {
indegree.set(target, indegree.get(target)! - 1);
if (indegree.get(target) === 0) {
ready.push(byName.get(target)!);
ready.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
}
}
}
if (resolved.length !== plugins.length) {
const cyclic = plugins
.filter((plugin) => !resolved.includes(plugin))
.map((plugin) => plugin.name);
throw new Error(`WRN-PLUGIN-CYCLE: ${cyclic.join(", ")}`);
}
return resolved;
}
export interface PluginRunner {
readonly plugins: readonly WrnexusPlugin[];
configure(config: Record<string, unknown>): Promise<void>;
configResolved(config: Readonly<Record<string, unknown>>): Promise<void>;
transformAst(ast: PageAst, file: string): Promise<PageAst>;
transformCode(code: string, file: string): Promise<string>;
diagnostics(ast: PageAst, file: string): Promise<WrnDiagnostic[]>;
hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise<void>;
}
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
const plugins = resolvePlugins(input);
const transformContext = (file: string): TransformContext => ({ ...context, file });
return {
plugins,
async configure(config) {
for (const plugin of plugins) await plugin.configure?.(config, context);
},
async configResolved(config) {
for (const plugin of plugins) await plugin.configResolved?.(config, context);
},
async transformAst(ast, file) {
let current = ast;
for (const plugin of plugins)
current = (await plugin.transformAst?.(current, transformContext(file))) ?? current;
return current;
},
async transformCode(code, file) {
let current = code;
for (const plugin of plugins)
current = (await plugin.transformCode?.(current, transformContext(file))) ?? current;
return current;
},
async diagnostics(ast, file) {
const all: WrnDiagnostic[] = [];
for (const plugin of plugins)
all.push(...((await plugin.diagnostics?.(ast, transformContext(file))) ?? []));
return all;
},
async hook(name, value) {
for (const plugin of plugins) {
if (name === "buildStart") await plugin.buildStart?.(context);
else if (name === "buildEnd") await plugin.buildEnd?.(value, context);
else await plugin.configureServer?.(value, context);
}
},
};
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { resolvePlugins } from "../src/index.ts";
describe("plugin ordering", () => {
test("orders pre, normal, and post plugins", () => {
expect(
resolvePlugins([
{ name: "post", enforce: "post" },
{ name: "normal" },
{ name: "pre", enforce: "pre" },
]).map((plugin) => plugin.name),
).toEqual(["pre", "normal", "post"]);
});
test("respects explicit dependencies", () => {
expect(
resolvePlugins([{ name: "b", after: ["a"] }, { name: "a" }]).map((plugin) => plugin.name),
).toEqual(["a", "b"]);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+80 -2
View File
@@ -20,6 +20,9 @@ export interface Job<T = unknown> {
runAt: number;
/** If set, re-enqueue this job this many ms after each successful run. */
repeat?: number;
priority: number;
idempotencyKey?: string;
createdAt: number;
}
export type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>;
@@ -31,6 +34,10 @@ export interface AddOptions {
maxAttempts?: number;
/** Re-enqueue this job this many ms after each successful run (recurring). */
repeat?: number;
/** Higher-priority jobs run first when multiple jobs are due. */
priority?: number;
/** Prevent duplicate queued work with the same stable key. */
idempotencyKey?: string;
}
export interface QueueOptions {
@@ -42,6 +49,8 @@ export interface QueueOptions {
pollMs?: number;
/** Called when a job exhausts its attempts. */
onFailed?: (job: Job, error: unknown) => void;
/** Maximum jobs executed in one drain. Default: unlimited. */
concurrency?: number;
/** Clock injection (tests). Default Date.now. */
now?: () => number;
}
@@ -54,18 +63,66 @@ export interface Queue {
start(): void;
stop(): void;
size(): number;
get(id: string): Job | undefined;
list(name?: string): Job[];
cancel(id: string): boolean;
}
export interface JobDefinition<I> {
name: string;
options?: Omit<AddOptions, "idempotencyKey">;
run: JobHandler<I>;
}
export function defineJob<I>(definition: JobDefinition<I>): JobDefinition<I> {
return definition;
}
export interface WorkflowStep<I, O> {
name: string;
run(input: I): O | Promise<O>;
}
export function defineWorkflow<T>(name: string, steps: Array<WorkflowStep<any, any>>) {
return {
name,
steps,
async run(input: T): Promise<unknown> {
let value: unknown = input;
for (const step of steps) value = await step.run(value);
return value;
},
};
}
export function cronToInterval(cron: string): number {
const aliases: Record<string, number> = {
"@hourly": 60 * 60 * 1000,
"@daily": 24 * 60 * 60 * 1000,
"@weekly": 7 * 24 * 60 * 60 * 1000,
};
if (aliases[cron]) return aliases[cron];
const everyMinutes = /^\*\/(\d+)\s+\*\s+\*\s+\*\s+\*$/.exec(cron.trim());
if (everyMinutes) return Number(everyMinutes[1]) * 60 * 1000;
throw new Error(`WRN-CRON-UNSUPPORTED: '${cron}'. Use @hourly, @daily, @weekly, or */N * * * *.`);
}
export function createQueue(options: QueueOptions = {}): Queue {
const defaultMax = options.maxAttempts ?? 3;
const backoffMs = options.backoffMs ?? 1000;
const pollMs = options.pollMs ?? 250;
const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
if (!Number.isInteger(defaultMax) || defaultMax < 1)
throw new RangeError("queue maxAttempts must be a positive integer");
if (!Number.isFinite(backoffMs) || backoffMs < 0)
throw new RangeError("queue backoffMs must be a non-negative number");
if (!Number.isFinite(pollMs) || pollMs < 1)
throw new RangeError("queue pollMs must be at least 1ms");
if (!(
concurrency === Number.POSITIVE_INFINITY ||
(Number.isInteger(concurrency) && concurrency > 0)
))
throw new RangeError("queue concurrency must be a positive integer");
const now = options.now ?? Date.now;
const jobs: Job[] = [];
@@ -100,7 +157,10 @@ export function createQueue(options: QueueOptions = {}): Queue {
draining = true;
try {
const cutoff = at ?? now();
const due = jobs.filter((j) => j.runAt <= cutoff && handlers.has(j.name));
const due = jobs
.filter((j) => j.runAt <= cutoff && handlers.has(j.name))
.sort((a, b) => b.priority - a.priority || a.runAt - b.runAt || a.createdAt - b.createdAt)
.slice(0, concurrency);
await Promise.all(due.map(runJob));
return due.length;
} finally {
@@ -120,14 +180,24 @@ export function createQueue(options: QueueOptions = {}): Queue {
throw new RangeError("job delayMs must be a non-negative number");
if (opts.repeat !== undefined && (!Number.isFinite(opts.repeat) || opts.repeat <= 0))
throw new RangeError("job repeat must be a positive number");
if (opts.priority !== undefined && !Number.isFinite(opts.priority))
throw new RangeError("job priority must be a finite number");
if (opts.idempotencyKey) {
const existing = jobs.find((job) => job.idempotencyKey === opts.idempotencyKey);
if (existing) return existing as Job<typeof data>;
}
const createdAt = now();
const job: Job = {
id: `job_${++seq}`,
name,
data,
attempts: 0,
maxAttempts: opts.maxAttempts ?? defaultMax,
runAt: now() + (opts.delayMs ?? 0),
runAt: createdAt + (opts.delayMs ?? 0),
repeat: opts.repeat,
priority: opts.priority ?? 0,
idempotencyKey: opts.idempotencyKey,
createdAt,
};
jobs.push(job);
return job as Job<typeof data>;
@@ -145,5 +215,13 @@ export function createQueue(options: QueueOptions = {}): Queue {
timer = null;
},
size: () => jobs.length,
get: (id) => jobs.find((job) => job.id === id),
list: (name) => jobs.filter((job) => !name || job.name === name).map((job) => ({ ...job })),
cancel(id) {
const index = jobs.findIndex((job) => job.id === id);
if (index < 0) return false;
jobs.splice(index, 1);
return true;
},
};
}
+35 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "bun:test";
import { createQueue } from "../src/index.ts";
import { createQueue, cronToInterval, defineWorkflow } from "../src/index.ts";
test("processes a job", async () => {
const queue = createQueue();
@@ -104,3 +104,37 @@ test("drains independent due jobs concurrently", async () => {
releases.forEach((release) => release());
expect(await draining).toBe(2);
});
test("prioritizes due jobs and respects concurrency", async () => {
const queue = createQueue({ concurrency: 1 });
const order: string[] = [];
queue.process<string>("work", (job) => {
order.push(job.data);
});
await queue.add("work", "low", { priority: 1 });
await queue.add("work", "high", { priority: 10 });
expect(await queue.drain()).toBe(1);
expect(order).toEqual(["high"]);
expect(queue.size()).toBe(1);
});
test("deduplicates, lists and cancels queued work", async () => {
const queue = createQueue();
const first = await queue.add("sync", { id: 1 }, { idempotencyKey: "customer:1" });
const second = await queue.add("sync", { id: 2 }, { idempotencyKey: "customer:1" });
expect(second.id).toBe(first.id);
expect(queue.list("sync")).toHaveLength(1);
expect(queue.get(first.id)?.data).toEqual({ id: 1 });
expect(queue.cancel(first.id)).toBe(true);
expect(queue.cancel(first.id)).toBe(false);
});
test("runs typed workflows and parses supported cron expressions", async () => {
const workflow = defineWorkflow<number>("double-and-label", [
{ name: "double", run: (value: number) => value * 2 },
{ name: "label", run: (value: number) => `value:${value}` },
]);
expect(await workflow.run(5)).toBe("value:10");
expect(cronToInterval("@hourly")).toBe(3_600_000);
expect(cronToInterval("*/5 * * * *")).toBe(300_000);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+3 -5
View File
@@ -1,5 +1,3 @@
/**
* @wrnexus/reactive tiny reactive primitives.
*/
export type { Signal, Subscriber, Unsubscribe } from "./signal.ts";
export { signal } from "./signal.ts";
/** @wrnexus/reactive — fine-grained reactive primitives. */
export type { Cleanup, ReadonlySignal, Signal, Subscriber, Unsubscribe } from "./signal.ts";
export { batch, computed, effect, signal, untrack } from "./signal.ts";
+129 -24
View File
@@ -1,53 +1,158 @@
/**
* A minimal, type-safe reactive signal with zero dependencies.
*
* This is the seed of the framework's reactivity. Today it powers nothing on
* its own, but it is shaped so client islands (and later the `.wrn` compiler's
* `state` blocks) can build reactive bindings on top of it.
*
* const count = signal(0)
* count.get() // 0
* count.set(1) // notifies subscribers
* const off = count.subscribe(v => console.log(v))
* off() // unsubscribe
* Fine-grained reactive primitives shared by server utilities and client code.
* Updates are synchronous by default and coalesced inside `batch()`.
*/
export type Subscriber<T> = (value: T) => void;
export type Subscriber<T> = (value: T, previous?: T) => void;
export type Unsubscribe = () => void;
export type Cleanup = () => void;
export interface Signal<T> {
/** Read the current value. */
get(): T;
/** Write a new value; subscribers run only when the value actually changes. */
set(next: T): void;
/** Apply a function to the current value. */
update(fn: (current: T) => T): void;
/** Subscribe to changes; returns an unsubscribe function. */
subscribe(fn: Subscriber<T>): Unsubscribe;
}
export interface ReadonlySignal<T> {
get(): T;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
type DependencyCollector = (subscribe: (subscriber: Subscriber<unknown>) => Unsubscribe) => void;
let activeCollector: DependencyCollector | null = null;
let batchDepth = 0;
const pending = new Set<() => void>();
function enqueue(job: () => void): void {
if (batchDepth > 0) pending.add(job);
else job();
}
function flush(): void {
while (pending.size > 0) {
const jobs = [...pending];
pending.clear();
for (const job of jobs) job();
}
}
/** Coalesce every signal notification made by `fn` into one flush. */
export function batch<T>(fn: () => T): T {
batchDepth++;
try {
return fn();
} finally {
batchDepth--;
if (batchDepth === 0) flush();
}
}
/** Read reactive values without recording dependencies. */
export function untrack<T>(fn: () => T): T {
const previous = activeCollector;
activeCollector = null;
try {
return fn();
} finally {
activeCollector = previous;
}
}
export function signal<T>(initial: T): Signal<T> {
let value = initial;
let pendingPrevious: T | undefined;
let queued = false;
const subscribers = new Set<Subscriber<T>>();
return {
const notify = (): void => {
queued = false;
const previous = pendingPrevious;
pendingPrevious = undefined;
for (const fn of [...subscribers]) fn(value, previous);
};
const api: Signal<T> = {
get(): T {
if (activeCollector) {
activeCollector((subscriber) => api.subscribe(subscriber as Subscriber<T>));
}
return value;
},
set(next: T): void {
if (Object.is(next, value)) return; // skip no-op updates
if (Object.is(next, value)) return;
const previous = value;
value = next;
// Iterate a copy so a subscriber may unsubscribe during notification.
for (const fn of [...subscribers]) fn(value);
if (!queued) {
queued = true;
pendingPrevious = previous;
enqueue(notify);
}
},
update(fn: (current: T) => T): void {
this.set(fn(value));
api.set(fn(value));
},
subscribe(fn: Subscriber<T>): Unsubscribe {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
return () => subscribers.delete(fn);
},
};
return api;
}
/**
* Run a dependency-tracked side effect. Dependencies are rebuilt after every
* execution, preventing stale subscriptions when conditional reads change.
*/
export function effect(run: () => void | Cleanup): Cleanup {
let disposed = false;
let cleanup: void | Cleanup;
let subscriptions: Cleanup[] = [];
let scheduled = false;
const execute = (): void => {
scheduled = false;
if (disposed) return;
if (typeof cleanup === "function") cleanup();
for (const unsubscribe of subscriptions) unsubscribe();
subscriptions = [];
const previous = activeCollector;
activeCollector = (subscribe) => {
subscriptions.push(
subscribe(() => {
if (scheduled || disposed) return;
scheduled = true;
enqueue(execute);
}),
);
};
try {
const nextCleanup = run();
cleanup = typeof nextCleanup === "function" ? nextCleanup : undefined;
} finally {
activeCollector = previous;
}
};
execute();
return () => {
if (disposed) return;
disposed = true;
if (typeof cleanup === "function") cleanup();
for (const unsubscribe of subscriptions) unsubscribe();
subscriptions = [];
};
}
/** Create a lazily readable derived signal with automatic dependency tracking. */
export function computed<T>(read: () => T): ReadonlySignal<T> {
const output = signal<T>(undefined as T);
effect(() => output.set(read()));
return {
get: output.get,
subscribe: output.subscribe,
};
}
+44 -14
View File
@@ -1,17 +1,47 @@
import { expect, test } from "bun:test";
import { signal } from "../src/index.ts";
import { describe, expect, test } from "bun:test";
import { batch, computed, effect, signal } from "../src/index.ts";
test("signal skips equal writes and supports safe unsubscribe during notification", () => {
const count = signal(0);
const seen: number[] = [];
let off = () => {};
off = count.subscribe((value) => {
seen.push(value);
off();
describe("reactive primitives", () => {
test("signals skip no-op updates", () => {
const value = signal(1);
const seen: number[] = [];
value.subscribe((next) => seen.push(next));
value.set(1);
value.set(2);
expect(seen).toEqual([2]);
});
test("batch coalesces notifications", () => {
const value = signal(0);
const seen: number[] = [];
value.subscribe((next) => seen.push(next));
batch(() => {
value.set(1);
value.set(2);
value.set(3);
});
expect(seen).toEqual([3]);
});
test("computed values and effects track dependencies", () => {
const count = signal(2);
const doubled = computed(() => count.get() * 2);
const seen: number[] = [];
const dispose = effect(() => {
seen.push(doubled.get());
});
count.set(3);
dispose();
count.set(4);
expect(seen).toEqual([4, 6]);
});
test("effects ignore accidental non-function return values", () => {
const value = signal(0);
const seen: number[] = [];
const dispose = effect(() => seen.push(value.get()) as unknown as void);
value.set(1);
dispose();
expect(seen).toEqual([0, 1]);
});
count.set(0);
count.update((value) => value + 1);
count.set(2);
expect(seen).toEqual([1]);
expect(count.get()).toBe(2);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+30 -8
View File
@@ -23,12 +23,19 @@ import {
compileRoutePattern,
matchRoute,
sortRoutes,
findRouteConflicts,
type Route,
type RouteMatch,
} from "./match.ts";
export type { Route, RouteMatch } from "./match.ts";
export { compileRoutePattern, matchRoute, sortRoutes } from "./match.ts";
export {
compileRoutePattern,
getRouteParams,
matchRoute,
sortRoutes,
findRouteConflicts,
} from "./match.ts";
export { generateRoutesFile } from "./routes-gen.ts";
export interface ComponentRef {
@@ -69,10 +76,13 @@ export interface RouterOptions {
* - drops a trailing `index` segment
* - prefixes with `prefix` (e.g. "/api")
*/
function fileToRoute(rel: string, prefix: string): string {
export function fileToRoute(rel: string, prefix = ""): string {
const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, "");
const segments = withoutExt.split("/").filter(Boolean);
if (segments[segments.length - 1] === "index") segments.pop();
const segments = withoutExt
.split("/")
.filter(Boolean)
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")));
if (["index", "page"].includes(segments[segments.length - 1] ?? "")) segments.pop();
const tail = segments.join("/");
const route = prefix + (tail ? "/" + tail : "");
return route === "" ? "/" : route;
@@ -81,8 +91,8 @@ function fileToRoute(rel: string, prefix: string): string {
function buildRoutes(files: ScannedFile[], prefix: string): Route[] {
const routes = files.map((f): Route => {
const raw = fileToRoute(f.rel, prefix);
const { regex, paramNames } = compileRoutePattern(raw);
return { raw, file: f.file, regex, paramNames };
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
return { raw, file: f.file, regex, paramNames, paramMeta };
});
return sortRoutes(routes);
}
@@ -93,8 +103,8 @@ function normalizeEmbeddedApiPath(path: string): string {
}
function routeFromRaw(raw: string, file: string): Route {
const { regex, paramNames } = compileRoutePattern(raw);
return { raw, file, regex, paramNames };
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
return { raw, file, regex, paramNames, paramMeta };
}
function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick<Router, "api" | "realtime"> {
@@ -121,6 +131,14 @@ function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick<Router, "api" | "rea
return { api, realtime };
}
function warnRouteConflicts(kind: string, routes: Route[]): void {
for (const conflict of findRouteConflicts(routes)) {
console.warn(
`[wrnexus] WRN-ROUTE-CONFLICT: duplicate ${kind} route '${conflict.raw}' in ${conflict.files.join(", ")}`,
);
}
}
/** Scan an app directory and build all route tables. */
export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
const pageFiles = scanDir(join(appDir, "pages"));
@@ -132,6 +150,10 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
...embedded.realtime,
]);
warnRouteConflicts("page", pages);
warnRouteConflicts("API", api);
warnRouteConflicts("realtime", realtime);
// Middleware runs in deterministic (alphabetical) order.
const middlewareFiles = scanDir(join(appDir, "middleware"))
.map((f) => f.file)
+126 -28
View File
@@ -1,10 +1,20 @@
/**
* Route compilation + matching.
*
* A "route" is a URL pattern compiled to a RegExp. We support static segments
* and dynamic `[param]` segments, e.g. `/users/[id]` -> `{ id }`.
* Supported segments:
* [id] required parameter
* [id?] optional parameter
* [[id]] optional parameter (directory-friendly form)
* [...slug] required catch-all
* [[...slug]] optional catch-all
*/
export interface RouteParam {
name: string;
optional: boolean;
catchAll: boolean;
}
export interface Route {
/** The human-readable route pattern, e.g. `/users/[id]`. */
raw: string;
@@ -14,6 +24,8 @@ export interface Route {
regex: RegExp;
/** Ordered names of dynamic params captured by `regex`. */
paramNames: string[];
/** Rich parameter metadata. Optional for compatibility with old manifests. */
paramMeta?: RouteParam[];
}
export interface RouteMatch {
@@ -23,52 +35,138 @@ export interface RouteMatch {
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
/** Compile a `/users/[id]` style pattern into a RegExp + param names. */
export function compileRoutePattern(raw: string): Pick<Route, "regex" | "paramNames"> {
if (raw === "/") {
return { regex: /^\/$/, paramNames: [] };
function parseParamSegment(segment: string): RouteParam | null {
let inner: string | null = null;
let optional = false;
if (segment.startsWith("[[") && segment.endsWith("]]")) {
inner = segment.slice(2, -2);
optional = true;
} else if (segment.startsWith("[") && segment.endsWith("]")) {
inner = segment.slice(1, -1);
if (inner.endsWith("?")) {
optional = true;
inner = inner.slice(0, -1);
}
}
const paramNames: string[] = [];
const parts = raw
if (inner === null) return null;
const catchAll = inner.startsWith("...");
const name = catchAll ? inner.slice(3) : inner;
if (!/^[A-Za-z_$][\w$-]*$/.test(name)) {
throw new Error(`WRN-ROUTE-PARAM: Invalid route parameter '${segment}'.`);
}
return { name, optional, catchAll };
}
/** Return parameter metadata without requiring callers to inspect the regex. */
export function getRouteParams(raw: string): RouteParam[] {
return raw
.split("/")
.filter(Boolean)
.map((segment) => {
const dynamic = segment.match(/^\[(.+)\]$/);
if (dynamic) {
paramNames.push(dynamic[1]!);
return "([^/]+)";
}
return segment.replace(ESCAPE_RE, "\\$&");
});
.map(parseParamSegment)
.filter((value): value is RouteParam => value !== null);
}
// Allow an optional trailing slash.
const regex = new RegExp("^/" + parts.join("/") + "/?$");
return { regex, paramNames };
/** Compile a WRNexus route pattern into a RegExp + parameter metadata. */
export function compileRoutePattern(
raw: string,
): Pick<Route, "regex" | "paramNames" | "paramMeta"> {
if (raw === "/") {
return { regex: /^\/$/, paramNames: [], paramMeta: [] };
}
const paramMeta: RouteParam[] = [];
let source = "^";
const segments = raw.split("/").filter(Boolean);
for (const segment of segments) {
const param = parseParamSegment(segment);
if (!param) {
source += `/${segment.replace(ESCAPE_RE, "\\$&")}`;
continue;
}
if (paramMeta.some((existing) => existing.name === param.name)) {
throw new Error(
`WRN-ROUTE-DUPLICATE-PARAM: Parameter '${param.name}' appears more than once in '${raw}'.`,
);
}
paramMeta.push(param);
const capture = param.catchAll ? "(.+?)" : "([^/]+)";
source += param.optional ? `(?:/${capture})?` : `/${capture}`;
}
source += "/?$";
return {
regex: new RegExp(source),
paramNames: paramMeta.map((param) => param.name),
paramMeta,
};
}
function routeSpecificity(route: Route): number[] {
const segments = route.raw.split("/").filter(Boolean);
let staticCount = 0;
let requiredCount = 0;
let optionalCount = 0;
let catchAllCount = 0;
for (const segment of segments) {
const param = parseParamSegment(segment);
if (!param) staticCount++;
else if (param.catchAll) catchAllCount++;
else if (param.optional) optionalCount++;
else requiredCount++;
}
return [
staticCount,
requiredCount,
-optionalCount,
-catchAllCount,
segments.length,
route.raw.length,
];
}
/**
* Order routes so that static routes win over dynamic ones, and longer/more
* specific routes win over shorter ones. Sorting once keeps matching simple.
* Order routes so static and constrained routes win over optional/catch-all
* routes. The ordering remains deterministic for identical specificity.
*/
export function sortRoutes(routes: Route[]): Route[] {
return [...routes].sort((a, b) => {
if (a.paramNames.length !== b.paramNames.length) {
return a.paramNames.length - b.paramNames.length; // fewer params first
const as = routeSpecificity(a);
const bs = routeSpecificity(b);
for (let i = 0; i < as.length; i++) {
if (as[i] !== bs[i]) return bs[i]! - as[i]!;
}
return b.raw.length - a.raw.length; // longer/more specific first
return a.raw.localeCompare(b.raw) || a.file.localeCompare(b.file);
});
}
/** Find duplicate URL patterns before request handling starts. */
export function findRouteConflicts(routes: Route[]): Array<{ raw: string; files: string[] }> {
const grouped = new Map<string, string[]>();
for (const route of routes) {
const files = grouped.get(route.raw) ?? [];
files.push(route.file);
grouped.set(route.raw, files);
}
return [...grouped]
.filter(([, files]) => files.length > 1)
.map(([raw, files]) => ({ raw, files }));
}
/** Find the first route whose pattern matches `pathname`. */
export function matchRoute(routes: Route[], pathname: string): RouteMatch | null {
for (const route of routes) {
const m = route.regex.exec(pathname);
if (!m) continue;
const match = route.regex.exec(pathname);
if (!match) continue;
const params: Record<string, string> = {};
try {
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(m[i + 1]!);
route.paramNames.forEach((name, index) => {
const value = match[index + 1];
if (value !== undefined) params[name] = decodeURIComponent(value);
});
} catch {
// A malformed percent-encoded path is not a valid route match. Treat it
+55 -18
View File
@@ -1,10 +1,9 @@
/**
* Typed-routes codegen. From the scanned page routes, emit `app/routes.gen.ts`
* with a `Routes` map (path param types) and an `href()` builder so links
* are checked at compile time (unknown path or missing param = type error).
* with a `Routes` map (path -> param types) and an `href()` builder.
*/
import type { Route } from "./match.ts";
import { getRouteParams, type Route } from "./match.ts";
export function generateRoutesFile(pages: Route[]): string {
const seen = new Set<string>();
@@ -12,35 +11,73 @@ export function generateRoutesFile(pages: Route[]): string {
for (const page of [...pages].sort((a, b) => a.raw.localeCompare(b.raw))) {
if (seen.has(page.raw)) continue;
seen.add(page.raw);
const type = page.paramNames.length
? `{ ${page.paramNames.map((n) => `${JSON.stringify(n)}: string`).join("; ")} }`
const params = page.paramMeta ?? getRouteParams(page.raw);
const type = params.length
? `{ ${params
.map((param) => {
const key = `${JSON.stringify(param.name)}${param.optional ? "?" : ""}`;
const value = param.catchAll ? "string | readonly string[]" : "string";
return `${key}: ${value}`;
})
.join("; ")} }`
: "Record<string, never>";
entries.push(` ${JSON.stringify(page.raw)}: ${type};`);
}
return `// AUTO-GENERATED by \`wrnexus dev\` do not edit.
// Typed routes: a compile-time map of every page path to its [param] types,
// plus an href() builder that fills params and rejects unknown paths.
return `// AUTO-GENERATED by \`wrnexus dev\` - do not edit.
// Typed routes support required, optional, and catch-all parameters.
export interface Routes {
${entries.join("\n") || " [path: string]: Record<string, string>;"}
}
export type RoutePath = keyof Routes;
type RouteValue = string | readonly string[] | undefined;
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
if (value === undefined) return "";
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
return values.map((part) => encodeURIComponent(part)).join("/");
}
export function href<P extends RoutePath>(
path: P,
...args: Routes[P] extends Record<string, never> ? [] : [params: Routes[P]]
...args: keyof Routes[P] extends never
? []
: Record<string, never> extends Routes[P]
? [params?: Routes[P]]
: [params: Routes[P]]
): string {
const params = (args[0] ?? {}) as Record<string, string>;
return String(path)
.split("/")
.map((seg) =>
seg.startsWith("[") && seg.endsWith("]")
? encodeURIComponent(params[seg.slice(1, -1)] ?? "")
: seg,
)
.join("/");
const params = (args[0] ?? {}) as Record<string, RouteValue>;
const output: string[] = [];
for (const segment of String(path).split("/").filter(Boolean)) {
let name: string | undefined;
let optional = false;
let catchAll = false;
if (segment.startsWith("[[") && segment.endsWith("]]")) {
optional = true;
name = segment.slice(2, -2);
} else if (segment.startsWith("[") && segment.endsWith("]")) {
name = segment.slice(1, -1);
if (name.endsWith("?")) {
optional = true;
name = name.slice(0, -1);
}
}
if (!name) {
output.push(segment);
continue;
}
if (name.startsWith("...")) {
catchAll = true;
name = name.slice(3);
}
const value = params[name];
if (value === undefined && optional) continue;
if (value === undefined) throw new Error(\`WRN-ROUTE-MISSING-PARAM: Missing route parameter '\${name}'.\`);
output.push(encodeRouteValue(value, catchAll));
}
return "/" + output.filter(Boolean).join("/");
}
`;
}
+42 -7
View File
@@ -1,42 +1,77 @@
import { test, expect } from "bun:test";
import { compileRoutePattern, generateRoutesFile, matchRoute, type Route } from "../src/index.ts";
import {
compileRoutePattern,
fileToRoute,
generateRoutesFile,
matchRoute,
sortRoutes,
type Route,
} from "../src/index.ts";
function route(raw: string): Route {
return { raw, file: raw, ...compileRoutePattern(raw) };
}
test("generateRoutesFile emits a Routes map with param types", () => {
const code = generateRoutesFile([route("/"), route("/about"), route("/users/[id]")]);
const code = generateRoutesFile([
route("/"),
route("/about"),
route("/users/[id]"),
route("/docs/[[...slug]]"),
]);
expect(code).toContain('"/": Record<string, never>;');
expect(code).toContain('"/about": Record<string, never>;');
expect(code).toContain('"/users/[id]": { "id": string };');
expect(code).toContain('"/docs/[[...slug]]": { "slug"?: string | readonly string[] };');
expect(code).toContain("export function href");
});
test("generated href() fills params and leaves static paths intact", async () => {
// Compile + import the generated module to exercise href() for real.
test("generated href() fills required, optional and catch-all params", async () => {
const code = generateRoutesFile([
route("/"),
route("/users/[id]"),
route("/org/[org]/team/[team]"),
route("/blog/[[page]]"),
route("/docs/[...slug]"),
]);
const { tmpdir } = await import("node:os");
const { writeFileSync, mkdirSync } = await import("node:fs");
const { join } = await import("node:path");
const { pathToFileURL } = await import("node:url");
const dir = join(tmpdir(), "wire-routes-test");
const dir = join(tmpdir(), "wrnexus-routes-test");
mkdirSync(dir, { recursive: true });
const file = join(dir, `r${Date.now()}.ts`);
writeFileSync(file, code);
const mod = (await import(pathToFileURL(file).href)) as {
href: (p: string, params?: Record<string, string>) => string;
href: (p: string, params?: Record<string, string | string[]>) => string;
};
expect(mod.href("/")).toBe("/");
expect(mod.href("/users/[id]", { id: "42" })).toBe("/users/42");
expect(mod.href("/org/[org]/team/[team]", { org: "acme", team: "core" })).toBe(
"/org/acme/team/core",
);
expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b"); // encoded
expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b");
expect(mod.href("/blog/[[page]]")).toBe("/blog");
expect(mod.href("/docs/[...slug]", { slug: ["guide", "start here"] })).toBe(
"/docs/guide/start%20here",
);
});
test("matches optional and catch-all routes", () => {
expect(matchRoute([route("/blog/[[page]]")], "/blog")?.params).toEqual({});
expect(matchRoute([route("/blog/[[page]]")], "/blog/2")?.params).toEqual({ page: "2" });
expect(matchRoute([route("/docs/[...slug]")], "/docs/a/b")?.params).toEqual({ slug: "a/b" });
expect(matchRoute([route("/docs/[[...slug]]")], "/docs")?.params).toEqual({});
});
test("static routes sort ahead of dynamic and catch-all routes", () => {
const sorted = sortRoutes([route("/docs/[...slug]"), route("/docs/[id]"), route("/docs/new")]);
expect(sorted.map((item) => item.raw)).toEqual(["/docs/new", "/docs/[id]", "/docs/[...slug]"]);
});
test("route groups and page/index filenames do not affect URLs", () => {
expect(fileToRoute("(marketing)/pricing/page.wrn")).toBe("/pricing");
expect(fileToRoute("(dashboard)/index.wrn")).toBe("/");
});
test("malformed encoded route params return no match instead of throwing", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+59
View File
@@ -177,3 +177,62 @@ function resolveSeoUrl(
return value;
}
}
export interface StreamRenderOptions extends Omit<RenderOptions, "body"> {
body: string | Promise<string> | AsyncIterable<string>;
}
function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
return (
typeof value === "object" &&
value !== null &&
Symbol.asyncIterator in value &&
typeof (value as AsyncIterable<string>)[Symbol.asyncIterator] === "function"
);
}
async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable<string> {
if (typeof body === "string") {
yield body;
return;
}
if (isAsyncIterable(body)) {
yield* body;
return;
}
yield await body;
}
/**
* Stream a complete document while preserving the exact head/body contract of
* `renderDocument`. Async iterables can flush a shell, primary content, and
* slower fragments without buffering the entire route.
*/
export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream<Uint8Array> {
const marker = "<!--__WRNEXUS_STREAM_BODY__-->";
const document = renderDocument({ ...opts, body: marker });
const [prefix, suffix] = document.split(marker);
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
async start(controller) {
try {
controller.enqueue(encoder.encode(prefix ?? ""));
for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk));
controller.enqueue(encoder.encode(suffix ?? ""));
controller.close();
} catch (error) {
controller.error(error);
}
},
});
}
export function streamDocumentResponse(
opts: StreamRenderOptions,
init: ResponseInit = {},
): Response {
const headers = new Headers(init.headers);
if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
return new Response(renderDocumentStream(opts), { ...init, headers });
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { renderDocument } from "../src/index.ts";
import { renderDocument, renderDocumentStream } from "../src/index.ts";
test("escapes metadata and script URLs while preserving trusted rendered body", () => {
const html = renderDocument({
@@ -35,3 +35,14 @@ test("always emits a document language and preserves an explicit language", () =
expect(explicit).toContain('<html data-theme="dark" lang="fr">');
expect(explicit).not.toContain('lang="en"');
});
test("streams async body chunks inside the document shell", async () => {
async function* body() {
yield "<h1>Shell</h1>";
yield "<p>Later</p>";
}
const html = await new Response(
renderDocumentStream({ meta: { title: "Stream" }, body: body() }),
).text();
expect(html).toContain('<div id="app"><h1>Shell</h1><p>Later</p></div>');
});
+4 -2
View File
@@ -1,12 +1,14 @@
{
"name": "@wrnexus/styles",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/uploader": "workspace:*"
"@wrnexus/uploader": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*"
}
}
+128 -1
View File
@@ -10,7 +10,8 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { SecurityConfig, SeoConfig } from "@wrnexus/core";
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
import type { PluginInput } from "@wrnexus/plugin";
import type { StorageConfig } from "@wrnexus/uploader";
import type { ThemeConfig } from "./theme.ts";
import type { FontConfig } from "./fonts.ts";
@@ -131,7 +132,59 @@ export interface DevToolbarConfig {
veryLargeImageBytes?: number;
}
export interface ExperimentalConfig {
serverComponents?: boolean;
streaming?: boolean;
partialHydration?: boolean;
typedRpc?: boolean;
pluginTransforms?: boolean;
[feature: string]: boolean | undefined;
}
export interface PerformanceConfig {
budgets?: PerformanceBudgets;
/** `warn` reports budget violations; `error` fails production builds. */
enforcement?: "off" | "warn" | "error";
analyze?: boolean;
}
export interface ObservabilityConfig {
enabled?: boolean;
serviceName?: string;
serverTiming?: boolean;
sampleRate?: number;
exporter?: "console" | "otlp" | "none";
endpoint?: string;
}
export interface TenancyConfig {
mode?: "subdomain" | "domain" | "path" | "custom";
required?: boolean;
rootDomains?: string[];
pathPrefix?: string;
}
export interface BuildConfig {
cache?: boolean;
cacheDir?: string;
sourceMaps?: boolean;
report?: boolean;
adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string;
}
export interface AppConfig {
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
plugins?: PluginInput;
/** Opt-in APIs that are not yet covered by stable compatibility guarantees. */
experimental?: ExperimentalConfig;
/** Route and asset budgets plus build analyzer behavior. */
performance?: PerformanceConfig;
/** Request tracing, Server-Timing, and exporter configuration. */
observability?: ObservabilityConfig;
/** First-class tenant resolution defaults. */
tenancy?: TenancyConfig;
/** Build cache, source map, report, and deployment adapter settings. */
build?: BuildConfig;
/** Development-only page diagnostics toolbar. Enabled by default in development. */
devToolbar?: boolean | DevToolbarConfig;
/** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */
@@ -237,6 +290,13 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise<
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
delete merged.profiles;
applyFontCsp(merged);
const issues = validateAppConfig(merged);
const errors = issues.filter((issue) => issue.severity === "error");
if (errors.length) {
throw new Error(
`Invalid wrnexus.config: ${errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
);
}
return merged;
}
@@ -306,6 +366,73 @@ function parseDotenv(content: string): Record<string, string> {
return out;
}
export interface ConfigIssue {
path: string;
severity: "error" | "warning";
message: string;
}
export function defineConfig(config: AppConfig): AppConfig {
return config;
}
export function validateAppConfig(config: AppConfig): ConfigIssue[] {
const issues: ConfigIssue[] = [];
const sampleRate = config.observability?.sampleRate;
if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) {
issues.push({
path: "observability.sampleRate",
severity: "error",
message: "must be between 0 and 1",
});
}
const budgets = config.performance?.budgets;
if (budgets) {
for (const [name, value] of Object.entries(budgets)) {
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
issues.push({
path: `performance.budgets.${name}`,
severity: "error",
message: "must be a non-negative finite number",
});
}
}
}
if (config.tenancy?.mode === "path" && !config.tenancy.pathPrefix) {
issues.push({
path: "tenancy.pathPrefix",
severity: "warning",
message: "is recommended when tenancy.mode is path",
});
}
return issues;
}
export interface ExplainedConfig {
profile: string;
config: AppConfig;
issues: ConfigIssue[];
sources: string[];
}
export async function explainAppConfig(
appRoot: string,
profile?: string,
): Promise<ExplainedConfig> {
const active = profile ?? resolveProfile();
const config = await loadAppConfig(appRoot, active);
const sources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name)));
const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter(
(name) => existsSync(join(appRoot, name)),
);
return {
profile: active,
config,
issues: validateAppConfig(config),
sources: [...sources, ...envSources],
};
}
/** Flatten a head config into a single HTML string. */
export function headToString(head?: string | string[]): string {
if (!head) return "";
+18 -1
View File
@@ -9,13 +9,30 @@
export type {
AppConfig,
BuildConfig,
ConfigIssue,
DevToolbarConfig,
ExplainedConfig,
ExperimentalConfig,
MobileConfig,
ObservabilityConfig,
PerformanceConfig,
TenancyConfig,
PwaConfig,
StylesConfig,
StyleProcessContext,
Mode,
} from "./config.ts";
export { loadAppConfig, loadRawConfig, headToString, resolveProfile, loadEnv } from "./config.ts";
export {
defineConfig,
explainAppConfig,
headToString,
loadAppConfig,
loadEnv,
loadRawConfig,
resolveProfile,
validateAppConfig,
} from "./config.ts";
export { findStyleEntry, bundleCss } from "./styles.ts";
export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts";
export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts";
+7
View File
@@ -0,0 +1,7 @@
# @wrnexus/syntax
Canonical WRN lexer, parser, AST, language metadata, source positions, and stable
diagnostics. Framework tooling should import this package instead of implementing a
separate `.wrn` parser.
See `docs/WRN-LANGUAGE-SPEC-1.0.md` in the WRNexusJS repository.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@wrnexus/syntax",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./parser": "./src/parser.ts",
"./tokenizer": "./src/tokenizer.ts",
"./types": "./src/types.ts",
"./diagnostics": "./src/diagnostics.ts",
"./spec": "./src/spec.ts"
}
}
+206
View File
@@ -0,0 +1,206 @@
import { parse, ParseError, type PageAst, type ViewNode } from "./parser.ts";
import {
WRN_DIAGNOSTIC_CODES,
WRN_HYDRATION_STRATEGIES,
WRN_RUNTIME_TARGETS,
type WrnHydrationStrategy,
type WrnRuntimeTarget,
} from "./spec.ts";
export type WrnDiagnosticSeverity = "error" | "warning" | "info";
export interface WrnSourcePosition {
offset: number;
line: number;
column: number;
}
export interface WrnDiagnostic {
code: string;
severity: WrnDiagnosticSeverity;
message: string;
hint?: string;
file?: string;
position?: WrnSourcePosition;
}
export interface DiagnoseOptions {
file?: string;
accessibility?: boolean;
}
export function positionAt(source: string, offset: number): WrnSourcePosition {
const safe = Math.max(0, Math.min(offset, source.length));
const before = source.slice(0, safe);
const lines = before.split(/\r?\n/);
return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
}
function offsetFromMessage(message: string): number | undefined {
const match = /offset\s+(\d+)/i.exec(message);
return match ? Number(match[1]) : undefined;
}
export function classifyParseError(message: string): string {
if (/Expected 'page', 'component', or 'layout'/.test(message)) return WRN_DIAGNOSTIC_CODES.root;
if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) {
return WRN_DIAGNOSTIC_CODES.member;
}
if (/prop initializer|Expected eq/.test(message)) return WRN_DIAGNOSTIC_CODES.propInitializer;
if (/State '.+' requires an initializer/.test(message)) {
return WRN_DIAGNOSTIC_CODES.stateInitializer;
}
if (/Cannot watch undeclared state/.test(message)) return WRN_DIAGNOSTIC_CODES.watchUndeclared;
return WRN_DIAGNOSTIC_CODES.parse;
}
export function diagnosticFromError(
source: string,
error: unknown,
options: DiagnoseOptions = {},
): WrnDiagnostic {
const message = error instanceof Error ? error.message : String(error);
const offset =
error instanceof ParseError && error.offset !== undefined
? error.offset
: offsetFromMessage(message);
return {
code: error instanceof ParseError ? error.code : classifyParseError(message),
severity: "error",
message,
file: options.file,
...(offset === undefined ? {} : { position: positionAt(source, offset) }),
};
}
function walk(nodes: ViewNode[], visit: (node: ViewNode) => void): void {
for (const node of nodes) {
visit(node);
if (node.type === "element") walk(node.children, visit);
else if (node.type === "each") {
walk(node.body, visit);
walk(node.empty, visit);
} else if (node.type === "if") {
for (const branch of node.branches) walk(branch.body, visit);
}
}
}
function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[] {
const diagnostics: WrnDiagnostic[] = [];
const seen = new Map<string, string>();
for (const [kind, declarations] of [
["prop", ast.props],
["state", ast.states],
["computed", ast.computed],
] as const) {
for (const declaration of declarations) {
const previous = seen.get(declaration.name);
if (previous) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.duplicateSymbol,
severity: "error",
message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`,
hint: "Rename one declaration so every prop, state, and computed value is unique.",
file: options.file,
});
} else {
seen.set(declaration.name, kind);
}
}
}
if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidHydration,
severity: "error",
message: `Unknown hydration strategy '${ast.hydrate}'.`,
hint: "Use load, idle, visible, interaction, none, or media:<query>.",
file: options.file,
});
}
if (ast.runtime && !isRuntimeTarget(ast.runtime)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidRuntime,
severity: "error",
message: `Unknown runtime target '${ast.runtime}'.`,
hint: "Use server, client, or universal.",
file: options.file,
});
}
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
walk(ast.view, (node) => {
if (node.type === "element" && node.attrs.some((attribute) => attribute.event))
interactive = true;
if (!options.accessibility || node.type !== "element") return;
const tag = node.tag.toLowerCase();
if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.accessibility,
severity: "warning",
message: "Image is missing an alt attribute.",
hint: 'Add alt text, or alt="" for a decorative image.',
file: options.file,
});
}
});
if (ast.runtime === "server" && interactive) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.serverInteractive,
severity: "error",
message:
"A server-only WRN root cannot contain client state, effects, watches, or event handlers.",
hint: 'Use runtime = "universal" or remove interactive behavior.',
file: options.file,
});
}
return diagnostics;
}
export function diagnose(source: string, options: DiagnoseOptions = {}): WrnDiagnostic[] {
try {
return astDiagnostics(parse(source), options);
} catch (error) {
return [diagnosticFromError(source, error, options)];
}
}
export function assertValidAst(ast: PageAst, options: DiagnoseOptions = {}): void {
const errors = astDiagnostics(ast, options).filter(
(diagnostic) => diagnostic.severity === "error",
);
if (!errors.length) return;
const first = errors[0]!;
throw new ParseError(first.message, first.code);
}
export function isHydrationStrategy(value: string): value is WrnHydrationStrategy {
return (
(WRN_HYDRATION_STRATEGIES as readonly string[]).includes(value) ||
(value.startsWith("media:") && value.length > "media:".length)
);
}
export function isRuntimeTarget(value: string): value is WrnRuntimeTarget {
return (WRN_RUNTIME_TARGETS as readonly string[]).includes(value);
}
export function formatDiagnostic(source: string, diagnostic: WrnDiagnostic): string {
const location = diagnostic.position
? `${diagnostic.file ?? "<inline .wrn>"}:${diagnostic.position.line}:${diagnostic.position.column}`
: (diagnostic.file ?? "<inline .wrn>");
const lines = [
`${diagnostic.code} ${diagnostic.severity.toUpperCase()}`,
"",
diagnostic.message,
"",
location,
];
if (diagnostic.position) {
const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? "";
lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`);
}
if (diagnostic.hint) lines.push("", `Hint: ${diagnostic.hint}`);
return lines.join("\n");
}
+46
View File
@@ -0,0 +1,46 @@
export { Lexer, LexError } from "./tokenizer.ts";
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
export type {
ActionBlock,
ApiBlock,
Attr,
ComputedDecl,
DataApiBlock,
DataMode,
EffectBlock,
LifecycleBlock,
LoadBlock,
ModeFunctionsBlock,
PageAst,
PropDecl,
RealtimeBlock,
RealtimeHandler,
SeoBlock,
StateDecl,
ViewNode,
WatchBlock,
} from "./parser.ts";
export {
eraseFunctionTypes,
inferredRuntimeType,
runtimeTypeOf,
validateTypedInitializer,
} from "./types.ts";
export type { RuntimeType } from "./types.ts";
export {
assertValidAst,
classifyParseError,
diagnose,
diagnosticFromError,
formatDiagnostic,
isHydrationStrategy,
isRuntimeTarget,
positionAt,
} from "./diagnostics.ts";
export type {
DiagnoseOptions,
WrnDiagnostic,
WrnDiagnosticSeverity,
WrnSourcePosition,
} from "./diagnostics.ts";
export * from "./spec.ts";
+953
View File
@@ -0,0 +1,953 @@
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
*
* Grammar (subset of the vision, but real):
*
* page <Name> {
* 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> } }
* 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";
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;
}
export interface ComputedDecl {
name: string;
expr: string;
}
export interface EffectBlock {
body: string;
}
export interface LoadBlock {
mode: "server" | "client";
body: string;
}
export interface ActionBlock {
name: string;
args: string[];
body: 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>] [key <expr>]} …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;
key?: 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 type LifecycleHookName = "mount" | "update" | "unmount";
export interface LifecycleBlock {
mount?: string;
update?: string;
unmount?: string;
}
export interface WatchBlock {
state: string;
body: string;
}
export interface RealtimeHandler {
event: string;
args: string[];
body: string;
}
export interface RealtimeBlock {
name: string;
handlers: RealtimeHandler[];
}
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;
}
export interface PageAst {
type: "page";
/** Static ES module imports declared before the WRN root declaration. */
imports: string[];
/**
* `page` is a route, `component` is a reusable fragment,
* and `layout` is a reusable page wrapper.
*/
kind: "page" | "component" | "layout";
name: string;
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
layout?: string;
/** Execution boundary metadata. Defaults to universal. */
runtime?: "server" | "client" | "universal";
/** Client hydration strategy. Defaults to load when interactivity is present. */
hydrate?: string;
/** Declared component props (empty for pages). */
props: PropDecl[];
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
types: string[];
states: StateDecl[];
computed: ComputedDecl[];
effects: EffectBlock[];
loads: LoadBlock[];
actions: ActionBlock[];
security: Record<string, string>;
seo: SeoBlock;
view: ViewNode[];
styles: string[];
functions: string[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
lifecycle: LifecycleBlock;
watches: WatchBlock[];
apis: ApiBlock[];
realtimes: RealtimeBlock[];
}
export class ParseError extends Error {
readonly code: string;
readonly offset?: number;
constructor(message: string, code = "WRN-PARSE-001") {
super(message);
this.name = "ParseError";
this.code = code;
const match = /offset\s+(\d+)/i.exec(message);
this.offset = match ? Number(match[1]) : undefined;
}
}
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 imports: string[] = [];
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
while (true) {
while (/\s/u.test(source[lx.pos] ?? "")) lx.pos++;
if (source.startsWith("//", lx.pos)) {
while (lx.pos < source.length && source[lx.pos] !== "\n") lx.pos++;
continue;
}
importPattern.lastIndex = lx.pos;
const statement = importPattern.exec(source);
if (!statement) break;
imports.push(statement[0].trim());
lx.pos = importPattern.lastIndex;
}
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 may contain a page, reusable component, or reusable layout.
const opener = lx.next();
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
throw new ParseError(
`Expected 'page', 'component', or 'layout' but got '${
opener.value || opener.type
}' at offset ${opener.pos}`,
);
}
const kind = opener.value as "page" | "component" | "layout";
const name = expect("ident").value;
expect("lbrace");
let layout: string | undefined;
let runtime: PageAst["runtime"];
let hydrate: string | undefined;
const props: PropDecl[] = [];
const types: string[] = [];
const states: StateDecl[] = [];
const computed: ComputedDecl[] = [];
const effects: EffectBlock[] = [];
const loads: LoadBlock[] = [];
const actions: ActionBlock[] = [];
const security: Record<string, string> = {};
const seo: SeoBlock = {};
const view: ViewNode[] = [];
const styles: string[] = [];
const functions: string[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const lifecycle: LifecycleBlock = {};
const watches: WatchBlock[] = [];
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 "runtime": {
lx.next();
expect("eq");
const value = expect("string").value;
if (value !== "server" && value !== "client" && value !== "universal") {
throw new ParseError(
`Unknown runtime target '${value}' at offset ${kw.pos}`,
"WRN-RUNTIME-TARGET",
);
}
runtime = value;
break;
}
case "hydrate": {
lx.next();
expect("eq");
hydrate = expect("string").value;
break;
}
case "props": {
// props { name: Type = <default> } — omit the default for required props.
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;
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.readPropInitializer() : "undefined";
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
}
expect("rbrace");
break;
}
case "state": {
lx.next();
const sName = expect("ident").value;
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 "computed": {
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 computed");
const name = expect("ident").value;
expect("eq");
computed.push({ name, expr: lx.readPropInitializer() });
}
expect("rbrace");
break;
}
case "effect": {
lx.next();
effects.push({ body: lx.readBalancedBraces() });
break;
}
case "types": {
lx.next();
types.push(lx.readBalancedBraces());
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 "security": {
lx.next();
Object.assign(security, parseSeoBlock(lx.readBalancedBraces()));
break;
}
case "load": {
lx.next();
const modeToken = expect("ident");
if (modeToken.value !== "server" && modeToken.value !== "client") {
throw new ParseError(
`Expected 'server' or 'client' after load at offset ${modeToken.pos}`,
);
}
loads.push({ mode: modeToken.value, body: lx.readBalancedBraces() });
break;
}
case "action": {
lx.next();
const actionName = expect("ident").value;
const args: string[] = [];
if (lx.peek().type === "lparen") {
lx.next();
while (lx.peek().type !== "rparen") {
args.push(expect("ident").value);
if (lx.peek().type === "comma") lx.next();
}
expect("rparen");
}
actions.push({ name: actionName, args, body: 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();
if (mode === "client" && lx.peek().type === "eq") {
lx.next();
hydrate = expect("string").value;
break;
}
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 "lifecycle": {
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const hook = lx.peek();
if (hook.type === "eof") {
throw new ParseError("Unexpected end of input inside lifecycle block");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
}
const hookName = hook.value as LifecycleHookName;
lx.next();
if (lifecycle[hookName] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
}
lifecycle[hookName] = lx.readBalancedBraces();
}
expect("rbrace");
break;
}
case "watch": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body,
});
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");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
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);
}
const symbols = new Set<string>();
for (const declaration of [...props, ...states, ...computed]) {
if (symbols.has(declaration.name)) {
throw new ParseError(`Duplicate symbol '${declaration.name}'`, "WRN-SYMBOL-DUPLICATE");
}
symbols.add(declaration.name);
}
return {
type: "page",
imports,
kind,
name,
layout,
runtime,
hydrate,
props,
types,
states,
computed,
effects,
loads,
actions,
security,
seo,
view,
styles,
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
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 isTagNamePart = (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 readTagName = (): string => {
if (i >= src.length || !isNameStart(src[i]!)) {
return fail("Expected a tag name");
}
const start = i++;
while (i < src.length && isTagNamePart(src[i]!)) {
i++;
}
return src.slice(start, i);
};
const readAttributeName = (): string => {
if (i >= src.length) {
return fail("Expected an attribute name");
}
const start = i;
while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (
char === "=" ||
char === ">" ||
char === '"' ||
char === "'" ||
char === " " ||
char === "\t" ||
char === "\n" ||
char === "\r" ||
(char === "/" && next === ">")
) {
break;
}
i++;
}
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i);
};
const parseTag = (): ViewNode => {
i++; // consume '<'
const tag = readTagName();
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 = readAttributeName();
skipWs();
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: true });
continue;
}
const name = readAttributeName();
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 = readTagName();
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+key\s+([\s\S]+?))?\s*\}$/;
/** Parse `{#each <list> as <item>[, <index>] [key <expr>]} …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 key = m[4]?.trim();
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, key, 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 };
}
+50
View File
@@ -0,0 +1,50 @@
/** Canonical, machine-readable WRN language capabilities. */
export const WRN_LANGUAGE_VERSION = "1.0";
export const WRN_ROOT_KINDS = ["page", "component", "layout"] as const;
export const WRN_ROOT_MEMBERS = [
"layout",
"runtime",
"hydrate",
"client",
"types",
"props",
"state",
"computed",
"effect",
"watch",
"lifecycle",
"view",
"seo",
"security",
"load",
"action",
"api",
"ssr",
"realtime",
"style",
"functions",
] as const;
export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const;
export const WRN_RUNTIME_TARGETS = ["server", "client", "universal"] as const;
export type WrnRootKind = (typeof WRN_ROOT_KINDS)[number];
export type WrnRootMember = (typeof WRN_ROOT_MEMBERS)[number];
export type WrnHydrationStrategy = (typeof WRN_HYDRATION_STRATEGIES)[number] | `media:${string}`;
export type WrnRuntimeTarget = (typeof WRN_RUNTIME_TARGETS)[number];
export const WRN_DIAGNOSTIC_CODES = {
parse: "WRN-PARSE-001",
root: "WRN-PARSE-ROOT",
member: "WRN-PARSE-MEMBER",
propInitializer: "WRN-PROP-INITIALIZER",
stateInitializer: "WRN-STATE-INITIALIZER",
watchUndeclared: "WRN-WATCH-UNDECLARED",
duplicateSymbol: "WRN-SYMBOL-DUPLICATE",
invalidHydration: "WRN-HYDRATE-STRATEGY",
invalidRuntime: "WRN-RUNTIME-TARGET",
serverInteractive: "WRN-RUNTIME-SERVER-INTERACTIVE",
accessibility: "WRN-A11Y-001",
} as const;
+321
View File
@@ -0,0 +1,321 @@
/**
* 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"
| "colon"
| "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: "colon", 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 a prop default initializer. The initializer may contain nested arrays,
* objects, calls, strings, or template literals. At top level it ends at a
* newline, the closing brace of the props block, or the next inline prop
* declaration (`name = ...` / `name: Type = ...`).
*/
readPropInitializer(): string {
const { src } = this;
while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) {
this.pos++;
}
const start = this.pos;
let square = 0;
let brace = 0;
let paren = 0;
let angle = 0;
let quote: string | null = null;
const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0;
while (this.pos < src.length) {
const c = src[this.pos]!;
if (quote) {
this.pos++;
if (c === "\\" && this.pos < src.length) {
this.pos++;
} else if (c === quote) {
quote = null;
}
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
this.pos++;
continue;
}
if (atTopLevel()) {
if (c === "\n" || c === "\r" || c === "}") break;
if (c === " " || c === "\t") {
let look = this.pos;
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
const rest = src.slice(look);
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) break;
}
}
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--;
else if (c === "<") angle++;
else if (c === ">" && angle > 0) angle--;
this.pos++;
}
const value = src.slice(start, this.pos).trim();
if (!value) throw new LexError(`Expected a prop initializer at offset ${start}`);
return value;
}
/** 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 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
* 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;
}
}
+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}`;
},
);
}
+94
View File
@@ -0,0 +1,94 @@
import { expect, test } from "bun:test";
import { diagnose, formatDiagnostic, parse } from "../src/index.ts";
test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => {
const ast = parse(`page Dashboard {
runtime = "universal"
hydrate = "visible"
state count = 1
computed {
doubled = count * 2
}
effect {
console.log(doubled)
}
security {
auth = "required"
csrf = "true"
}
load server {
return { count: 1 }
}
load client {
return { refreshed: true }
}
action save(input) {
return input
}
view { <button @click="count++">{doubled}</button> }
}`);
expect(ast.runtime).toBe("universal");
expect(ast.hydrate).toBe("visible");
expect(ast.computed).toEqual([{ name: "doubled", expr: "count * 2" }]);
expect(ast.effects).toHaveLength(1);
expect(ast.security).toEqual({ auth: "required", csrf: "true" });
expect(ast.loads.map((load) => load.mode)).toEqual(["server", "client"]);
expect(ast.actions).toEqual([expect.objectContaining({ name: "save", args: ["input"] })]);
});
test("diagnoses server-only interactive roots and accessibility issues", () => {
const diagnostics = diagnose(
`component AvatarButton {
runtime = "server"
state open = false
view {
<button @click="open = true"><img src="/avatar.png"></button>
}
}`,
{ file: "AvatarButton.wrn", accessibility: true },
);
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(
"WRN-RUNTIME-SERVER-INTERACTIVE",
);
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("WRN-A11Y-001");
});
test("formats parser diagnostics with stable codes and source locations", () => {
const source = `page Broken { runtime = "worker"\n view { <main></main> } }`;
const [diagnostic] = diagnose(source, { file: "Broken.wrn" });
expect(diagnostic?.code).toBe("WRN-RUNTIME-TARGET");
expect(formatDiagnostic(source, diagnostic!)).toContain("Broken.wrn");
});
test("parses keyed each blocks without changing legacy loop syntax", () => {
const keyed = parse(`component Rows {
props { rows = [] }
view {
{#each rows as row, index key row.id}
<p>{index}: {row.name}</p>
{/each}
}
}`);
const loop = keyed.view.find((node) => node.type === "each");
expect(loop).toEqual(
expect.objectContaining({
type: "each",
list: "rows",
item: "row",
index: "index",
key: "row.id",
}),
);
const legacy = parse(`component Rows {
props { rows = [] }
view { {#each rows as row}<p>{row.name}</p>{/each} }
}`);
expect(legacy.view.find((node) => node.type === "each")).toEqual(
expect.objectContaining({ type: "each", key: undefined }),
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -9,7 +9,7 @@ component ProductHero {
view {
<section
class=" relative isolate overflow-hidden border-b border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-bg,#ffffff)] px-4 py-14 sm:px-6 sm:py-18 lg:px-8 lg:py-20 {class} "
class=" relative isolate overflow-hidden bg-[var(--wire-color-bg,#ffffff)] px-4 py-14 sm:px-6 sm:py-18 lg:px-8 lg:py-20 {class} "
>
<div class="pointer-events-none absolute inset-0 -z-10 opacity-80" style="background: radial-gradient(circle at 16% 0%, color-mix(in srgb, var(--wire-color-primary, #059669) 16%, transparent), transparent 36%);" aria-hidden="true" ></div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.79",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/index.ts",