release: WRNexusJS 0.3.0
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)};`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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.
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user