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
+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);
});