release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.5.14",
"version": "0.6.0",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.5.14",
"version": "0.6.0",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+609 -4
View File
@@ -28,7 +28,7 @@ import {
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { basename, dirname, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { inspectProject, type DoctorCheck } from "./doctor.ts";
@@ -70,9 +70,20 @@ interface MigrationCtx {
from: string;
to: string;
dryRun: boolean;
explicitImports: boolean;
report: MigrationReport;
log: (msg: string) => void;
}
export interface MigrationReport {
changedAutomatically: string[];
needsReview: string[];
unresolvedImports: string[];
ambiguousFunctions: string[];
legacyOutputPayloads: string[];
parseFailures: string[];
}
interface Migration {
/** Framework version that introduced this change. Runs when `from < version <= to`. */
version: string;
@@ -181,6 +192,373 @@ export function migrateWrnSource(source: string): string {
return formatInlineProps(quoteLegacyDynamicAttributes(source));
}
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
let depth = 0;
let quote = "";
for (let index = open; index < source.length; index++) {
const char = source[index]!;
if (quote) {
if (char === "\\") index++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'" || char === "`") quote = char;
else if (char === openChar) depth++;
else if (char === closeChar && --depth === 0) return index;
}
return -1;
}
function convertLegacyEmit(source: string, report: MigrationReport, file: string): string {
let output = "";
let cursor = 0;
while (true) {
const start = source.indexOf("$emit(", cursor);
if (start < 0) return output + source.slice(cursor);
output += source.slice(cursor, start);
const end = findMatching(source, start + 5, "(", ")");
if (end < 0) {
report.needsReview.push(`${file}: unterminated $emit call`);
return output + source.slice(start);
}
const args = source.slice(start + 6, end);
const match = args.match(/^\s*(["'])([A-Za-z_$][\w$:-]*)\1\s*(?:,\s*([\s\S]*))?$/);
if (!match) {
report.needsReview.push(`${file}: dynamic $emit requires manual review`);
output += source.slice(start, end + 1);
} else {
const payload = (match[3] ?? "").trim();
output += payload ? `output.${match[2]}(${payload})` : `output.${match[2]}()`;
}
cursor = end + 1;
}
}
function collectLegacyEvents(source: string): { source: string; outputs: string[] } {
const outputs: string[] = [];
const next = source.replace(
/(^|[\s;])@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\b[ \t]*(?:\r?\n)?/gm,
(_whole, prefix: string, name: string) => {
outputs.push(name);
return prefix;
},
);
return { source: next, outputs };
}
function ensureOutputsBlock(
source: string,
names: string[],
report: MigrationReport,
file: string,
): string {
if (!names.length) return source;
const unique = [...new Set(names)];
const blockMatch = /\boutputs\s*\{/.exec(source);
if (blockMatch) {
const open = source.indexOf("{", blockMatch.index);
const close = findMatching(source, open);
if (close < 0) return source;
const body = source.slice(open + 1, close);
const missing = unique.filter((name) => !new RegExp(`\\b${name}\\s*\\(`).test(body));
if (!missing.length) return source;
const indent = source.slice(source.lastIndexOf("\n", blockMatch.index) + 1, blockMatch.index);
const additions = missing.map((name) => `${indent} ${name}(payload: unknown)`).join("\n");
for (const name of missing)
report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
return source.slice(0, close) + `\n${additions}\n${indent}` + source.slice(close);
}
const rootOpen = source.indexOf("{");
if (rootOpen < 0) return source;
const lineStart = source.lastIndexOf("\n", rootOpen) + 1;
const nonWhitespace = source.slice(lineStart).search(/\S/);
const rootIndent = nonWhitespace < 0 ? "" : source.slice(lineStart, lineStart + nonWhitespace);
const indent = `${rootIndent} `;
const block = `\n${indent}outputs {\n${unique.map((name) => `${indent} ${name}(payload: unknown)`).join("\n")}\n${indent}}\n`;
for (const name of unique) report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
return source.slice(0, rootOpen + 1) + block + source.slice(rootOpen + 1);
}
function classifyLegacyFunctions(source: string, report: MigrationReport, file: string): string {
const functionsMatch = /\bfunctions\s*\{/.exec(source);
if (!functionsMatch) return source;
const open = source.indexOf("{", functionsMatch.index);
const close = findMatching(source, open);
if (close < 0) return source;
const body = source.slice(open + 1, close);
let output = "";
let cursor = 0;
const header =
/(^|\n)([ \t]*)(?!client\s|server\s|shared\s)(async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)(?:\s*:\s*[^{\n]+)?\s*\{/g;
for (let match; (match = header.exec(body));) {
const bodyOpen = body.indexOf("{", match.index + match[0].length - 1);
const bodyClose = findMatching(body, bodyOpen);
if (bodyClose < 0) break;
const functionBody = body.slice(bodyOpen + 1, bodyClose);
const client =
/\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|MouseEvent|KeyboardEvent|output\.|refs\.)\b/.test(
functionBody,
);
const server =
/\b(ctx|Bun|process|database|db\.|serverService|authService|request|response)\b/.test(
functionBody,
);
let runtime = "";
if (client && !server) runtime = "client ";
else if (server && !client) runtime = "server ";
else if (!client && !server && !/\b(fetch|Date\.now|Math\.random)\b/.test(functionBody))
runtime = "shared ";
else report.ambiguousFunctions.push(`${file}: ${match[4]}`);
output +=
body.slice(cursor, match.index) +
match[1] +
match[2] +
runtime +
(match[3] ?? "") +
`function ${match[4]}`;
const consumedHeader =
match[0].lastIndexOf(`function ${match[4]}`) + `function ${match[4]}`.length;
output += match[0].slice(consumedHeader);
cursor = match.index + match[0].length;
header.lastIndex = bodyClose + 1;
}
output += body.slice(cursor);
return source.slice(0, open + 1) + output + source.slice(close);
}
export function migrateV060WrnSource(
source: string,
report: MigrationReport,
file = "<inline>",
): string {
const legacy = collectLegacyEvents(source);
let next = ensureOutputsBlock(legacy.source, legacy.outputs, report, file);
next = convertLegacyEmit(next, report, file);
next = next.replace(/\bevent\.detail\b/g, "payload");
next = classifyLegacyFunctions(next, report, file);
return migrateWrnSource(next);
}
function updateV060Config(ctx: MigrationCtx): void {
const candidates = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
const file = candidates.map((name) => join(ctx.appRoot, name)).find(existsSync);
if (!file) return;
const current = readFileSync(file, "utf8");
if (/\bimports\s*:/.test(current) && /\bcompatibility\s*:/.test(current)) return;
const insertion = `\n imports: { mode: "compatible", autoImport: true, aliases: { "@": "./app" } },\n types: { strict: false, noImplicitAny: false, strictNullChecks: true, checkTemplates: true, checkComponentProps: true, generateDeclarations: true, globalTypes: "./app/types/global.d.ts" },\n functions: { legacyDefaultRuntime: "current" },\n stores: { strictMutations: true, persistence: true },\n compatibility: { legacyEmit: true, legacyEventProps: true, legacyComponentDiscovery: true, stringLayouts: true },`;
const index = current.lastIndexOf("}");
if (index < 0) return;
const next =
current.slice(0, index).replace(/,?\s*$/, "") + "," + insertion + "\n" + current.slice(index);
ctx.log(`~ ${file.slice(ctx.appRoot.length + 1)}: v0.6 compatibility configuration`);
if (!ctx.dryRun) writeFileSync(file, next, "utf8");
}
interface V060SymbolIndex {
components: Map<string, string[]>;
layouts: Map<string, string[]>;
ui: Set<string>;
}
function declarationName(file: string): string {
const source = readFileSync(file, "utf8");
return (
/\b(?:component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source)?.[1] ?? basename(file, ".wrn")
);
}
function addSymbol(index: Map<string, string[]>, name: string, file: string): void {
const current = index.get(name) ?? [];
current.push(file);
index.set(name, current);
}
function loadUiComponentNames(appRoot: string): Set<string> {
const candidates = [
join(appRoot, "node_modules", "@wrnexus", "ui", "component-reference.json"),
join(appRoot, "packages", "ui", "component-reference.json"),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
components?: Array<{ name?: string }>;
};
return new Set(
(parsed.components ?? [])
.map((entry) => entry.name)
.filter((name): name is string => Boolean(name)),
);
} catch {
// Continue to an empty package index. The migration reports unresolved tags.
}
}
return new Set();
}
function buildV060SymbolIndex(appRoot: string): V060SymbolIndex {
const components = new Map<string, string[]>();
const layouts = new Map<string, string[]>();
for (const file of walkProjectFiles(join(appRoot, "app", "components"), ".wrn")) {
addSymbol(components, declarationName(file), file);
}
for (const file of walkProjectFiles(join(appRoot, "app", "layouts"), ".wrn")) {
const name = declarationName(file);
addSymbol(layouts, name, file);
addSymbol(layouts, basename(file, ".wrn"), file);
}
return { components, layouts, ui: loadUiComponentNames(appRoot) };
}
function importedLocalNames(source: string): Set<string> {
const names = new Set<string>();
for (const statement of source.matchAll(
/^import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["'][^"']+["']\s*;?/gm,
)) {
const clause = statement[1]!.trim();
if (!clause.startsWith("{") && !clause.startsWith("*")) names.add(clause.split(",")[0]!.trim());
for (const named of clause.matchAll(
/(?:\{|,)\s*(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?/g,
)) {
names.add(named[2] ?? named[1]!);
}
}
return names;
}
function importPathFor(appRoot: string, file: string): string {
return `@/${relative(join(appRoot, "app"), file).replace(/\\/g, "/")}`;
}
function insertImports(source: string, statements: string[]): string {
if (!statements.length) return source;
const unique = [...new Set(statements)].filter((statement) => !source.includes(statement));
if (!unique.length) return source;
const imports = Array.from(
source.matchAll(
/^import\s+[\s\S]*?(?:;\s*|\n(?=import|\s*(?:page|component|layout|global\s+store|page\s+store)\b))/gm,
),
);
const insertion = imports.length ? imports.at(-1)!.index! + imports.at(-1)![0].length : 0;
const prefix = insertion ? "\n" : "";
return (
source.slice(0, insertion) +
prefix +
unique.join("\n") +
"\n\n" +
source.slice(insertion).replace(/^\s*/, "")
);
}
function addExplicitImportsToSource(
source: string,
file: string,
appRoot: string,
index: V060SymbolIndex,
report: MigrationReport,
): string {
const imported = importedLocalNames(source);
const rootName =
/\b(?:page|component|layout|global\s+store|page\s+store)\s+([A-Za-z_$][\w$]*)/.exec(
source,
)?.[1];
const applicationImports: string[] = [];
const uiImports = new Set<string>();
const tags = new Set(
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
);
for (const name of tags) {
if (name === rootName || imported.has(name)) continue;
const candidates = index.components.get(name) ?? [];
if (candidates.length === 1) {
applicationImports.push(
`import ${name} from ${JSON.stringify(importPathFor(appRoot, candidates[0]!))}`,
);
imported.add(name);
} else if (candidates.length > 1) {
report.unresolvedImports.push(
`${file}: component '${name}' has ${candidates.length} application matches`,
);
} else if (index.ui.has(name)) {
uiImports.add(name);
imported.add(name);
} else {
report.unresolvedImports.push(`${file}: component '${name}' could not be resolved`);
}
}
if (uiImports.size) {
const existingUi = /import\s*\{([\s\S]*?)\}\s*from\s*["']@wrnexus\/ui["']\s*;?/.exec(source);
if (existingUi) {
const names = new Set([
...Array.from(
existingUi[1]!.matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)/g),
(match) => match[1]!,
),
...uiImports,
]);
source = source.replace(
existingUi[0],
`import {\n${[...names]
.sort()
.map((name) => ` ${name},`)
.join("\n")}\n} from "@wrnexus/ui"`,
);
} else {
applicationImports.push(
`import {\n${[...uiImports]
.sort()
.map((name) => ` ${name},`)
.join("\n")}\n} from "@wrnexus/ui"`,
);
}
}
return insertImports(source, applicationImports);
}
function migrateImportedLayout(
source: string,
file: string,
appRoot: string,
index: V060SymbolIndex,
report: MigrationReport,
): string {
const match = /\blayout\s*=\s*["']([^"']+)["']/.exec(source);
if (!match || match[1] === "none") return source;
const candidates = index.layouts.get(match[1]) ?? [];
const unique = [...new Set(candidates)];
if (unique.length !== 1) {
report.unresolvedImports.push(
`${file}: layout '${match[1]}' ${unique.length ? "is ambiguous" : "was not found"}`,
);
return source;
}
const symbol = declarationName(unique[0]!);
const statement = `import ${symbol} from ${JSON.stringify(importPathFor(appRoot, unique[0]!))}`;
return insertImports(source.replace(match[0], `layout = ${symbol}`), [statement]);
}
function writeMigrationReport(ctx: MigrationCtx): void {
const file = join(ctx.appRoot, ".wrnexus", "migrations", "0.6.0-report.json");
ctx.log(`+ .wrnexus/migrations/0.6.0-report.json`);
if (ctx.dryRun) return;
mkdirSync(dirname(file), { recursive: true });
writeFileSync(
file,
JSON.stringify(
{
version: "0.6.0",
from: ctx.from,
to: ctx.to,
appliedAt: new Date().toISOString(),
...ctx.report,
},
null,
2,
) + "\n",
"utf8",
);
}
/**
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
* migrations must preserve semantics and are protected by update backups.
@@ -1186,6 +1564,179 @@ const MIGRATIONS: Migration[] = [
// Compiler, SSR, dev-server, and CSR runtime changes apply automatically.
},
},
{
version: "0.6.0",
id: "0.6.0-01-parser-and-language",
description:
"Enables the v0.6 parser, AST, typed state, imports, stores, outputs, and runtime function grammar.",
apply() {},
},
{
version: "0.6.0",
id: "0.6.0-02-function-runtime",
description:
"Classifies unambiguous legacy functions as client, server, or shared while preserving ambiguous behavior.",
apply(ctx) {
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const after = classifyLegacyFunctions(before, ctx.report, relative);
if (after === before) continue;
ctx.log(`~ ${relative}: runtime function modifiers`);
ctx.report.changedAutomatically.push(relative);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
},
},
{
version: "0.6.0",
id: "0.6.0-03-typed-outputs",
description:
"Converts legacy @event declarations to typed outputs with safe unknown payload fallbacks.",
apply(ctx) {
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const legacy = collectLegacyEvents(before);
const after = ensureOutputsBlock(legacy.source, legacy.outputs, ctx.report, relative);
if (after === before) continue;
ctx.log(`~ ${relative}: typed outputs`);
ctx.report.changedAutomatically.push(relative);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
},
},
{
version: "0.6.0",
id: "0.6.0-04-remove-emit",
description:
"Migrates static $emit calls and event.detail consumers to output.name(payload) and payload.",
apply(ctx) {
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const after = convertLegacyEmit(before, ctx.report, relative).replace(
/\bevent\.detail\b/g,
"payload",
);
if (after === before) continue;
ctx.log(`~ ${relative}: callable outputs`);
ctx.report.changedAutomatically.push(relative);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
},
},
{
version: "0.6.0",
id: "0.6.0-05-explicit-imports",
description:
"Enables compatible explicit imports and records unresolved symbols without guessing.",
apply(ctx) {
if (!ctx.explicitImports) return;
const index = buildV060SymbolIndex(ctx.appRoot);
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const before = readFileSync(path, "utf8");
const after = addExplicitImportsToSource(
before,
relativeFile,
ctx.appRoot,
index,
ctx.report,
);
if (after === before) continue;
ctx.log(`~ ${relativeFile}: explicit imports`);
if (!ctx.report.changedAutomatically.includes(relativeFile))
ctx.report.changedAutomatically.push(relativeFile);
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
}
},
},
{
version: "0.6.0",
id: "0.6.0-06-layout-imports",
description:
"Converts safely resolvable string layouts to imported symbols while retaining compatibility.",
apply(ctx) {
if (!ctx.explicitImports) return;
const index = buildV060SymbolIndex(ctx.appRoot);
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const before = readFileSync(path, "utf8");
const after = migrateImportedLayout(before, relativeFile, ctx.appRoot, index, ctx.report);
if (after === before) continue;
ctx.log(`~ ${relativeFile}: imported layout`);
if (!ctx.report.changedAutomatically.includes(relativeFile))
ctx.report.changedAutomatically.push(relativeFile);
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
}
},
},
{
version: "0.6.0",
id: "0.6.0-07-component-contracts",
description: "Enables generated component, output, function, and declaration contracts.",
apply() {},
},
{
version: "0.6.0",
id: "0.6.0-08-store-support",
description: "Adds @wrnexus/store and @wrnexus/typecheck to runnable applications.",
apply(ctx) {
if (!existsSync(join(ctx.appRoot, "app", "pages"))) return;
const file = join(ctx.appRoot, "package.json");
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
const dependencies = (pkg.dependencies ??= {});
const added: string[] = [];
for (const name of ["@wrnexus/store", "@wrnexus/typecheck"]) {
if (dependencies[name] === `^${ctx.to}`) continue;
dependencies[name] = `^${ctx.to}`;
added.push(name);
}
if (added.length) ctx.log(`+ dependencies: ${added.join(", ")}`);
if (!ctx.dryRun && added.length)
writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
},
},
{
version: "0.6.0",
id: "0.6.0-09-type-config",
description:
"Adds compatible imports, type checking, stores, and legacy behavior configuration.",
apply(ctx) {
updateV060Config(ctx);
const types = join(ctx.appRoot, "app", "types");
if (!existsSync(types)) {
ctx.log("+ app/types/global.d.ts");
if (!ctx.dryRun) {
mkdirSync(types, { recursive: true });
writeFileSync(
join(types, "global.d.ts"),
"// Application-wide ambient WRNexusJS types.\nexport {};\n",
"utf8",
);
}
}
},
},
{
version: "0.6.0",
id: "0.6.0-10-showcase-and-ui",
description: "Finalizes v0.6 source normalization and writes the detailed migration report.",
apply(ctx) {
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
const after = migrateWrnSource(before);
if (after === before) continue;
ctx.log(`~ ${relative}: final v0.6 normalization`);
if (!ctx.report.changedAutomatically.includes(relative))
ctx.report.changedAutomatically.push(relative);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
writeMigrationReport(ctx);
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
@@ -1291,7 +1842,12 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
}
/** Update one app without marking success until install and verification pass. */
export function updateApp(appRoot: string, target: string, dryRun: boolean): UpdatedApp | null {
export function updateApp(
appRoot: string,
target: string,
dryRun: boolean,
options: { explicitImports?: boolean; report?: MigrationReport } = {},
): UpdatedApp | null {
const pkgPath = join(appRoot, "package.json");
if (!existsSync(pkgPath)) {
console.log(`${appRoot}: no package.json — skipped`);
@@ -1300,6 +1856,16 @@ export function updateApp(appRoot: string, target: string, dryRun: boolean): Upd
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
const from = appVersion(appRoot, pkg);
const log = (m: string) => console.log(` ${m}`);
const report =
options.report ??
({
changedAutomatically: [],
needsReview: [],
unresolvedImports: [],
ambiguousFunctions: [],
legacyOutputPayloads: [],
parseFailures: [],
} satisfies MigrationReport);
console.log(`${pkg.name ?? appRoot} (${from}${target})`);
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
@@ -1312,7 +1878,15 @@ export function updateApp(appRoot: string, target: string, dryRun: boolean): Upd
for (const m of MIGRATIONS) {
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
m.apply({ appRoot, from, to: target, dryRun, log });
m.apply({
appRoot,
from,
to: target,
dryRun,
explicitImports: options.explicitImports ?? false,
report,
log,
});
}
}
@@ -1383,6 +1957,8 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
const root = resolve(dir);
const dryRun = args.includes("--dry-run");
const verify = !args.includes("--no-verify");
const explicitImports = args.includes("--explicit-imports");
const reportRequested = args.includes("--report");
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
const target =
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
@@ -1417,7 +1993,36 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
// Workspace → update the root manifest + every app; else just this app.
const apps = await workspaceApps(root);
const targets = apps ? [root, ...apps] : [root];
const updated = targets.map((t) => updateApp(t, target, dryRun)).filter(Boolean) as UpdatedApp[];
const reports = new Map<string, MigrationReport>();
const updated = targets
.map((targetRoot) => {
const report: MigrationReport = {
changedAutomatically: [],
needsReview: [],
unresolvedImports: [],
ambiguousFunctions: [],
legacyOutputPayloads: [],
parseFailures: [],
};
reports.set(targetRoot, report);
return updateApp(targetRoot, target, dryRun, { explicitImports, report });
})
.filter(Boolean) as UpdatedApp[];
if (reportRequested || dryRun) {
for (const [targetRoot, report] of reports) {
console.log(`
Migration report: ${targetRoot}`);
console.log(` Changed automatically: ${new Set(report.changedAutomatically).size}`);
console.log(` Needs review: ${report.needsReview.length}`);
console.log(` Unresolved imports: ${report.unresolvedImports.length}`);
console.log(` Ambiguous function runtime: ${report.ambiguousFunctions.length}`);
console.log(
` Legacy output payloads using unknown: ${report.legacyOutputPayloads.length}`,
);
console.log(` Parse failures: ${report.parseFailures.length}`);
}
}
if (dryRun) {
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { migrateV060WrnSource } from "../src/update.ts";
const report = () => ({
changedAutomatically: [],
needsReview: [],
unresolvedImports: [],
ambiguousFunctions: [],
legacyOutputPayloads: [],
parseFailures: [],
});
describe("v0.6 source migration", () => {
test("converts legacy outputs and remains idempotent", () => {
const input = `component Demo {
props {
@event confirm = function
}
functions {
function confirm() {
$emit("confirm", { ok: true })
}
}
view { <button @click='confirm()'>Confirm</button> }
}`;
const first = migrateV060WrnSource(input, report(), "Demo.wrn");
const second = migrateV060WrnSource(first, report(), "Demo.wrn");
expect(first).toContain("outputs {");
expect(first).toContain("confirm(payload: unknown)");
expect(first).toContain("output.confirm({ ok: true })");
expect(second).toBe(first);
});
});
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "@wrnexus/compiler",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/syntax": "workspace:*"
"@wrnexus/syntax": "workspace:*",
"@wrnexus/store": "workspace:*"
}
}
+200
View File
@@ -0,0 +1,200 @@
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
const RESERVED_BINDINGS = new Set([
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
]);
const RUNTIME_BINDINGS = new Set([
"context",
"state",
"output",
"server",
"props",
"refs",
"event",
"payload",
]);
function safeIdentifier(name: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
}
function functionEntry(
ast: PageAst,
fn: RuntimeFunctionDecl,
availableFunctions: string[],
): string {
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
const stateNames = ast.states
.filter(
(state) =>
state.runtime !== "server" &&
safeIdentifier(state.name) &&
!RUNTIME_BINDINGS.has(state.name) &&
!parameterNames.has(state.name),
)
.map((state) => state.name);
const stateSet = new Set(stateNames);
const propNames = ast.props
.filter(
(prop) =>
safeIdentifier(prop.name) &&
!RUNTIME_BINDINGS.has(prop.name) &&
!parameterNames.has(prop.name) &&
!stateSet.has(prop.name),
)
.map((prop) => prop.name);
const functionAliases = availableFunctions.filter(
(name) =>
safeIdentifier(name) &&
!RUNTIME_BINDINGS.has(name) &&
!parameterNames.has(name) &&
!stateSet.has(name) &&
!propNames.includes(name),
);
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
const initialStateSnapshot = stateNames.length
? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };`
: "";
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
const syncStateFromContext = stateNames
.map((name) => `${name} = context.state.${name};`)
.join(" ");
const peerAliases = functionAliases
.map((name) => {
const call = `context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs)`;
if (!stateNames.length) {
return `const ${name} = (...__wrnexusPeerArgs) => ${call};`;
}
return `const ${name} = (...__wrnexusPeerArgs) => {
${syncStateToContext}
let __wrnexusPeerResult;
try {
__wrnexusPeerResult = ${call};
} catch (__wrnexusPeerError) {
${syncStateFromContext}
throw __wrnexusPeerError;
}
if (__wrnexusPeerResult && typeof __wrnexusPeerResult.then === "function") {
return Promise.resolve(__wrnexusPeerResult).finally(() => { ${syncStateFromContext} });
}
${syncStateFromContext}
return __wrnexusPeerResult;
};`;
})
.join("\n");
const copyBack = stateNames
.map(
(name) =>
`if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`,
)
.join("\n");
const body = eraseFunctionTypes(fn.body);
const runtimeBindings = [
!parameterNames.has("output") ? "const output = context.output;" : "",
!parameterNames.has("server") ? "const server = context.server;" : "",
!parameterNames.has("props") ? "const props = context.props;" : "",
!parameterNames.has("refs") ? "const refs = context.refs;" : "",
]
.filter(Boolean)
.join("\n ");
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(context${parameters ? `, ${parameters}` : ""}) {
${initialStateSnapshot}
${stateAliases}
${propAliases}
${peerAliases}
${runtimeBindings}
try {
${body}
} finally {
${copyBack}
}
}`;
}
export function generateBrowserModule(ast: PageAst): string {
const functions = ast.runtimeFunctions.filter((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
);
const functionNames = functions.map((fn) => fn.name);
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
const storeImports = ast.structuredImports.filter(
(entry) =>
!entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source),
);
const imports = ast.structuredImports
.filter((entry) => !entry.typeOnly)
.filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source))
.map((entry) => entry.raw)
.join("\n");
const importedBindings = storeImports
.flatMap((entry) => [
...(entry.defaultImport ? [entry.defaultImport] : []),
...(entry.namespaceImport ? [entry.namespaceImport] : []),
...entry.namedImports.map((item) => item.local),
])
.filter(safeIdentifier);
return `// generated WRNexusJS browser module for ${ast.name}
${imports}
export const __wrnexusClientFunctions = {
${functions.map((fn) => ` ${functionEntry(ast, fn, functionNames)}`).join(",\n")}
};
export const __wrnexusClientState = ${JSON.stringify(state)};
export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)};
export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} };
export function bindClientScope(context) {
const functions = {};
const scopedContext = { ...context, functions };
for (const [name, handler] of Object.entries(__wrnexusClientFunctions)) {
functions[name] = (...args) => handler(scopedContext, ...args);
}
return functions;
}
`;
}
+116 -33
View File
@@ -19,6 +19,8 @@ import { Buffer } from "node:buffer";
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
interface RenderBinding {
method: string;
@@ -765,6 +767,38 @@ function localStyleExport(ast: PageAst, styles: string[]): string | null {
)};`;
}
function isStoreImportSource(source: string): boolean {
return /(?:^|\/)stores?\//.test(source) || source.startsWith("@wrnexus/store");
}
function importedStoreBindings(ast: PageAst): Array<{ local: string; internal: string }> {
return ast.structuredImports
.filter(
(entry) =>
entry.defaultImport && entry.source.endsWith(".wrn") && isStoreImportSource(entry.source),
)
.map((entry) => ({
local: entry.defaultImport!,
internal: `__wrnexusStoreDefinition_${entry.defaultImport}`,
}));
}
function generatedImports(ast: PageAst): string[] {
const stores = new Map(importedStoreBindings(ast).map((entry) => [entry.local, entry.internal]));
return ast.structuredImports.map((entry) => {
if (!entry.defaultImport) return entry.raw;
const internal = stores.get(entry.defaultImport);
return internal
? entry.raw.replace(
new RegExp(
`^(\\s*import\\s+(?:type\\s+)?)(?:${entry.defaultImport.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")})(\\s+from\\s+)`,
),
`$1${internal}$2`,
)
: entry.raw;
});
}
function isSafeGeneratedIdentifier(name: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
}
@@ -781,22 +815,47 @@ function generateSsrStateAliases(stateNames: string[]): string {
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")}"`;
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
);
const moduleAttribute = hasBrowserModule
? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"'
: "";
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`;
}
function targetFunctions(ast: PageAst, target: "browser" | "server"): string {
const runtimes =
target === "browser"
? (["legacy", "client", "shared"] as const)
: (["legacy", "server", "shared"] as const);
return ast.functions
.map((body) => stripRuntimeFunctionModifiers(body, [...runtimes]))
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
}
function publicOutputNames(ast: PageAst): string[] {
return [
...new Set([
...ast.outputs.map((output) => output.name),
...ast.events.map((event) => event.name),
]),
];
}
export function generate(ast: PageAst): string {
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") {
return generateComponent(ast);
}
const out: string[] = [];
if (ast.imports.length > 0) out.push(ast.imports.join("\n"));
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
const ssrBindings: SsrBinding[] = [];
const csrBindings: CsrBinding[] = [];
const helpers = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const helpers = targetFunctions(ast, "server");
const apiBindings = apiBindingMap(ast, helpers);
const typeSource = ast.types
@@ -811,7 +870,10 @@ 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)};`);
if (ast.layout)
out.push(
`export const layout = ${ast.layoutIsSymbol ? ast.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))};`);
@@ -820,6 +882,7 @@ export function generate(ast: PageAst): string {
}
// --- View -> default page component ---
const browserStates = ast.states.filter((state) => state.runtime !== "server");
const seedScope = evalStateSeeds(ast.states);
for (const entry of ast.computed) {
try {
@@ -831,7 +894,7 @@ export function generate(ast: PageAst): string {
}
}
const reactiveNames = [
...ast.states.map((entry) => entry.name),
...browserStates.map((entry) => entry.name),
...ast.computed.map((entry) => entry.name),
];
const runtimeStateNames = new Set(
@@ -849,7 +912,7 @@ export function generate(ast: PageAst): string {
const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast);
const needsClientRuntime =
ast.runtime !== "server" &&
(ast.states.length > 0 ||
(browserStates.length > 0 ||
ast.computed.length > 0 ||
hasClientBehavior(ast.view) ||
pageBehavior !== null);
@@ -887,9 +950,17 @@ export function generate(ast: PageAst): string {
.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`)
.join("; ")} }`
: "Record<string, never>";
const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name));
const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name));
const storeBindings = importedStoreBindings(ast);
const storeDeclarations = storeBindings
.map(
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
)
.join("\n");
loops.forEach((code, idx) => {
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
});
@@ -918,10 +989,12 @@ export function generate(ast: PageAst): string {
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(
`export default async function ${ast.name}(ctx: any) {
${storeDeclarations}
${decls}
const __state: ${stateType} = { ${dynamicStateScope} };
${ssrStateAliases}
const __scopeValue = Object.entries(__state)
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
const __scopeValue = Object.entries(__hydrationState)
.map(([key, value]) => {
const encoded =
typeof value === "number" || typeof value === "boolean"
@@ -946,10 +1019,12 @@ export function generate(ast: PageAst): string {
);
} else {
out.push(
`export default function ${ast.name}(ctx: any) {
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
${storeDeclarations}
const __state: ${stateType} = { ${dynamicStateScope} };
${ssrStateAliases}
const __scopeValue = Object.entries(__state)
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
const __scopeValue = Object.entries(__hydrationState)
.map(([key, value]) => {
const encoded =
typeof value === "number" || typeof value === "boolean"
@@ -1062,6 +1137,10 @@ interface CompCtx {
interface ComponentBehavior {
functions: string;
outputs: Array<{
name: string;
payload?: { name: string; valueType: string; optional: boolean };
}>;
computed: Array<{ name: string; expr: string }>;
effects: string[];
lifecycle: {
@@ -1150,12 +1229,7 @@ function escLit(s: string): string {
}
function componentBehavior(ast: PageAst): ComponentBehavior | null {
const functions = eraseFunctionTypes(
ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n"),
);
const functions = eraseFunctionTypes(targetFunctions(ast, "browser"));
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
@@ -1175,6 +1249,7 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
if (
!functions &&
ast.outputs.length === 0 &&
computed.length === 0 &&
effects.length === 0 &&
Object.keys(lifecycle).length === 0 &&
@@ -1185,6 +1260,7 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
return {
functions,
outputs: ast.outputs,
computed,
effects,
lifecycle,
@@ -1613,7 +1689,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
function generateComponent(ast: PageAst): string {
const out: string[] = [];
if (ast.imports.length > 0) out.push(ast.imports.join("\n"));
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
const hasServerEach = viewHasServerEach(ast.view);
const effectiveProps =
@@ -1629,8 +1705,9 @@ function generateComponent(ast: PageAst): string {
]
: ast.props;
const browserStates = ast.states.filter((state) => state.runtime !== "server");
const stateNames = new Set([
...ast.states.map((entry) => entry.name),
...browserStates.map((entry) => entry.name),
...ast.computed.map((entry) => entry.name),
]);
const nameRefs = new Map<string, string>();
@@ -1652,21 +1729,13 @@ function generateComponent(ast: PageAst): string {
const ctx: CompCtx = {
stateNames,
functionNames: new Set(
ast.functions.flatMap((body) => {
return Array.from(
body.matchAll(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g),
(match) => match[1]!,
);
}),
ast.runtimeFunctions.filter((fn) => fn.runtime !== "server").map((fn) => fn.name),
),
resolveExpr,
eventNames: ast.events.map((event) => event.name),
eventNames: publicOutputNames(ast),
};
const serverFunctions = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const serverFunctions = targetFunctions(ast, "server");
const hasExplicitRestSpread = viewHasRestAttributeSpread(ast.view);
const rootElementIndex = ast.view.findIndex((node) => node.type === "element");
@@ -1694,7 +1763,7 @@ function generateComponent(ast: PageAst): string {
const needsScope =
ast.runtime !== "server" &&
(ast.states.length > 0 ||
(browserStates.length > 0 ||
ast.computed.length > 0 ||
viewHasEvents(ast.view) ||
behavior !== null);
@@ -1705,7 +1774,7 @@ function generateComponent(ast: PageAst): string {
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name),
...browserStates.map((state) => state.name),
];
const behaviorAttr = behaviorAttribute(behavior);
@@ -1791,6 +1860,17 @@ function generateComponent(ast: PageAst): string {
);
}
if (ast.outputs.length > 0) {
out.push(
`export interface ${ast.name}Outputs {\n${ast.outputs
.map(
(output) =>
` ${JSON.stringify(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
)
.join("\n")}\n}`,
);
}
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
if (v === undefined || v === null) {
return def;
@@ -2023,6 +2103,9 @@ function __wireRaw(v: any): string {
`}`,
);
out.push(
`export default { name: ${JSON.stringify(ast.name)}, kind: ${JSON.stringify(ast.kind)}, render };`,
);
return out.join("\n\n") + "\n";
}
@@ -0,0 +1,87 @@
import type { PageAst } from "@wrnexus/syntax";
export interface ComponentContractMetadata {
name: string;
kind: PageAst["kind"];
props: Array<{
name: string;
type: string;
required: boolean;
default?: string;
options?: string[];
}>;
outputs: Array<{ name: string; payloadName?: string; payloadType?: string }>;
functions: Array<{
name: string;
runtime: string;
async: boolean;
parameters: Array<{ name: string; type: string; optional: boolean }>;
returnType: string;
}>;
states: Array<{ name: string; runtime: string; type: string; initializer: string }>;
computed: Array<{ name: string; type: string; expression: string }>;
imports: Array<{
source: string;
typeOnly: boolean;
defaultImport?: string;
namedImports: string[];
}>;
}
function unionOptions(type: string | undefined): string[] | undefined {
if (!type || !type.includes("|")) return undefined;
const values = type
.split("|")
.map((part) => part.trim())
.filter((part) => /^(?:"[^"]*"|'[^']*')$/.test(part))
.map((part) => part.slice(1, -1));
return values.length ? values : undefined;
}
export function createComponentContract(ast: PageAst): ComponentContractMetadata {
return {
name: ast.name,
kind: ast.kind,
props: ast.props.map((prop) => ({
name: prop.name,
type: prop.valueType ?? "unknown",
required: prop.required,
...(prop.default !== "undefined" ? { default: prop.default } : {}),
...(unionOptions(prop.valueType) ? { options: unionOptions(prop.valueType) } : {}),
})),
outputs: ast.outputs.map((output) => ({
name: output.name,
...(output.payload
? { payloadName: output.payload.name, payloadType: output.payload.valueType }
: {}),
})),
functions: ast.runtimeFunctions.map((fn) => ({
name: fn.name,
runtime: fn.runtime,
async: fn.async,
parameters: fn.parameters.map((param) => ({
name: param.name,
type: param.valueType ?? "unknown",
optional: param.optional,
})),
returnType: fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown"),
})),
states: ast.states.map((state) => ({
name: state.name,
runtime: state.runtime,
type: state.valueType ?? "unknown",
initializer: state.expr,
})),
computed: ast.computed.map((entry) => ({
name: entry.name,
type: entry.valueType ?? "unknown",
expression: entry.expr,
})),
imports: ast.structuredImports.map((entry) => ({
source: entry.source,
typeOnly: entry.typeOnly,
...(entry.defaultImport ? { defaultImport: entry.defaultImport } : {}),
namedImports: entry.namedImports.map((named) => named.local),
})),
};
}
+60
View File
@@ -0,0 +1,60 @@
import { existsSync, realpathSync } from "node:fs";
import { dirname, extname, join, resolve } from "node:path";
import type { StructuredImportDecl } from "@wrnexus/syntax";
export type ImportMode = "legacy" | "compatible" | "explicit";
export interface ImportResolverOptions {
appRoot: string;
mode?: ImportMode;
aliases?: Record<string, string>;
}
export interface ResolvedImport {
declaration: StructuredImportDecl;
resolved?: string;
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
}
function candidates(path: string): string[] {
return extname(path)
? [path]
: [
path,
`${path}.wrn`,
`${path}.ts`,
`${path}.d.ts`,
join(path, "index.wrn"),
join(path, "index.ts"),
];
}
export function resolveWrnImport(
declaration: StructuredImportDecl,
importer: string,
options: ImportResolverOptions,
): ResolvedImport {
const source = declaration.source;
if (!source.startsWith(".") && !source.startsWith("@/")) return { declaration, resolved: source };
const aliasRoot = options.aliases?.["@"] ?? "./app";
const base = source.startsWith("@/")
? resolve(options.appRoot, aliasRoot, source.slice(2))
: resolve(dirname(importer), source);
const found = candidates(base).find(existsSync);
if (found) return { declaration, resolved: realpathSync(found) };
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
return {
declaration,
diagnostic: {
code: "WRN-IMPORT-NOT-FOUND",
message: `Cannot resolve import '${source}' from ${importer}`,
severity,
},
};
}
export function resolveWrnImports(
declarations: StructuredImportDecl[],
importer: string,
options: ImportResolverOptions,
): ResolvedImport[] {
return declarations.map((declaration) => resolveWrnImport(declaration, importer, options));
}
+13
View File
@@ -27,6 +27,14 @@ export {
ParseError,
} from "@wrnexus/syntax";
export { generate } from "./codegen.ts";
export { generateTargets } from "./targets.ts";
export { generateBrowserModule } from "./client-codegen.ts";
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
export { generateDeclarations } from "./type-codegen.ts";
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
export { createComponentContract } from "./component-contract.ts";
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
export { createWrnSourceMap } from "./source-map.ts";
export { generateNative, NativeCompileError } from "./native-codegen.ts";
export { Lexer, LexError } from "@wrnexus/syntax";
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
@@ -39,6 +47,11 @@ export type {
DataMode,
EffectBlock,
EventDecl,
OutputDecl,
RuntimeFunctionDecl,
StateRuntime,
StoreKind,
StructuredImportDecl,
LoadBlock,
ModeFunctionsBlock,
PageAst,
+68
View File
@@ -0,0 +1,68 @@
import { stripRuntimeFunctionModifiers, type PageAst } from "@wrnexus/syntax";
export interface RpcManifestEntry {
id: string;
component: string;
function: string;
parameters: Array<{ name: string; type: string; optional: boolean }>;
returnType: string;
}
function stableId(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `wrn_${(hash >>> 0).toString(36)}`;
}
/**
* Remote exposure is reference based in v0.6. A server function is included in
* the RPC manifest only when browser-capable code calls `server.<name>(...)`.
* Server functions remain available to SSR/server modules without becoming
* remotely callable by default.
*/
export function remotelyReferencedServerFunctions(ast: PageAst): Set<string> {
const browserSources = ast.runtimeFunctions
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
.map((fn) => fn.body);
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
if (hook !== "serverInit" && body) browserSources.push(body);
}
const names = new Set<string>();
const call = /\bserver\.([A-Za-z_$][\w$]*)\s*\(/g;
for (const source of browserSources) {
for (const match of source.matchAll(call)) names.add(match[1]!);
}
return names;
}
export function rpcManifest(ast: PageAst): RpcManifestEntry[] {
const exposed = remotelyReferencedServerFunctions(ast);
return ast.runtimeFunctions
.filter((fn) => fn.runtime === "server" && exposed.has(fn.name))
.map((fn) => ({
id: stableId(`${ast.name}:${fn.name}`),
component: ast.name,
function: fn.name,
parameters: fn.parameters.map((param) => ({
name: param.name,
type: param.valueType ?? "unknown",
optional: param.optional,
})),
returnType: fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown"),
}));
}
export function generateServerFunctionsModule(ast: PageAst): string {
const source = ast.functions
.map((body) => stripRuntimeFunctionModifiers(body, ["legacy", "server", "shared"]))
.filter(Boolean)
.join("\n\n");
const names = ast.runtimeFunctions
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
.map((fn) => fn.name);
const manifest = rpcManifest(ast);
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
}
+23
View File
@@ -0,0 +1,23 @@
export interface WrnSourceMapEntry {
generatedLine: number;
sourceLine: number;
sourceColumn: number;
kind: string;
}
export interface WrnSourceMap {
version: 1;
source: string;
generated: string;
mappings: WrnSourceMapEntry[];
}
export function createWrnSourceMap(source: string, generated: string): WrnSourceMap {
const sourceLines = source.split(/\r?\n/).length;
const generatedLines = generated.split(/\r?\n/).length;
const mappings = Array.from({ length: Math.min(sourceLines, generatedLines) }, (_, index) => ({
generatedLine: index + 1,
sourceLine: index + 1,
sourceColumn: 1,
kind: "line",
}));
return { version: 1, source, generated, mappings };
}
+406
View File
@@ -0,0 +1,406 @@
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
import { generateDeclarations } from "./type-codegen.ts";
import { rpcManifest } from "./server-codegen.ts";
const RESERVED_BINDINGS = new Set([
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
]);
function safeBinding(name: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
}
function stateObject(ast: PageAst, runtime: "shared" | "client" | "server"): string {
const entries = ast.states
.filter((state) => state.runtime === runtime)
.map((state) => `${JSON.stringify(state.name)}: (${state.expr})`);
return `{ ${entries.join(", ")} }`;
}
function actionSource(fn: RuntimeFunctionDecl, stateNames: string[], eraseTypes = false): string {
const parameterNames = new Set(fn.parameters.map((param) => param.name));
const params = fn.parameters.map((param) => param.name).join(", ");
const aliases = stateNames.filter((name) => safeBinding(name) && !parameterNames.has(name));
const aliasSource = aliases.length ? `let { ${aliases.join(", ")} } = context.state;` : "";
const runtimeAliases = ["server"]
.filter((name) => !parameterNames.has(name))
.map((name) => `const ${name} = context.${name};`)
.join("\n");
const copyBack = aliases.map((name) => `context.state.${name} = ${name};`).join("\n");
const body = eraseTypes ? eraseFunctionTypes(fn.body) : fn.body;
return `{ runtime: ${JSON.stringify(fn.runtime)}, handler: ${fn.async ? "async " : ""}function(context${params ? `, ${params}` : ""}) { ${runtimeAliases}\n${aliasSource}\ntry { ${body} } finally { ${copyBack} } } }`;
}
function persistedCallback(
source: string | undefined,
functionName: "migrate" | "validate",
): string | undefined {
if (!source?.trim()) return undefined;
const body = eraseFunctionTypes(source);
if (functionName === "migrate") {
return `(value, fromVersion, toVersion) => {\n${body}\nif (typeof migrate === "function") return migrate(value, fromVersion, toVersion);\nreturn value;\n}`;
}
return `(value) => {\n${body}\nif (typeof validate === "function") return validate(value);\nreturn value && typeof value === "object" && !Array.isArray(value) ? value : null;\n}`;
}
function persistenceSource(ast: PageAst): string {
if (!ast.persist) return "undefined";
const migrate = persistedCallback(ast.persist.migrations, "migrate");
const validate = persistedCallback(ast.persist.validation, "validate");
return `{ storage: ${JSON.stringify(ast.persist.storage)}, include: ${JSON.stringify(ast.persist.include)}, version: ${ast.persist.version}${migrate ? `, migrate: ${migrate}` : ""}${validate ? `, validate: ${validate}` : ""} }`;
}
function lifecycleSource(ast: PageAst, stateNames: string[], browser: boolean): string {
return Object.entries(ast.storeLifecycle)
.filter(([name]) => !browser || name !== "serverInit")
.map(([name, body]) => {
const aliases = stateNames.filter(safeBinding);
const aliasSource = aliases.length ? `let { ${aliases.join(", ")} } = context.state;` : "";
const runtimeAliases = browser ? "const server = context.server;" : "";
const copyBack = aliases.map((state) => `context.state.${state} = ${state};`).join("\n");
const emittedBody = browser ? eraseFunctionTypes(body) : body;
return `${name}: async (context) => { ${runtimeAliases} ${aliasSource} try { ${emittedBody} } finally { ${copyBack} } }`;
})
.join(",\n");
}
export function generateStoreModule(ast: PageAst): string {
if (ast.kind !== "global-store" && ast.kind !== "page-store") {
throw new Error("generateStoreModule requires a store AST");
}
const stateNames = ast.states.map((state) => state.name);
const safeStateNames = stateNames.filter(safeBinding);
const computed = ast.computed
.map(
(entry) =>
`${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`,
)
.join(",\n");
const actionGroups = new Map<string, RuntimeFunctionDecl[]>();
for (const fn of ast.runtimeFunctions) {
const group = actionGroups.get(fn.name) ?? [];
group.push(fn);
actionGroups.set(fn.name, group);
}
const actions = Array.from(
actionGroups,
([name, functions]) =>
`${JSON.stringify(name)}: [${functions.map((fn) => actionSource(fn, stateNames)).join(", ")}]`,
).join(",\n");
const persistence = persistenceSource(ast);
const lifecycle = lifecycleSource(ast, stateNames, false);
const manifest = rpcManifest(ast);
const remoteFunctions = manifest.map((entry) => entry.function);
const rpcWrappers = remoteFunctions
.map(
(name) => `${JSON.stringify(name)}: async (...received) => {
const rpcContext = received.pop();
if (!rpcContext || !rpcContext.request) throw new Error("WRN-RPC-CONTEXT: request context is required");
let container = __wrnexusRpcContainers.get(rpcContext.request);
if (!container) {
const url = new URL(rpcContext.request.url);
container = createRequestStoreContainer(rpcContext.request, url.pathname + url.search);
__wrnexusRpcContainers.set(rpcContext.request, container);
}
const store = await container.use(${ast.name});
const action = store.actions[${JSON.stringify(name)}];
if (typeof action !== "function") throw new Error(${JSON.stringify(`WRN-RPC-FUNCTION: store action '${name}' is unavailable on the server`)});
return action(...received);
}`,
)
.join(",\n");
return `${ast.imports.join("\n")}\nimport { defineStore } from "@wrnexus/store";\nimport { createRequestStoreContainer } from "@wrnexus/store/server";\n\n${ast.types.join("\n\n")}\n\nexport const ${ast.name} = defineStore({\n name: ${JSON.stringify(ast.name)},\n kind: ${JSON.stringify(ast.storeKind)},\n createSharedState: () => (${stateObject(ast, "shared")}),\n createClientState: () => (${stateObject(ast, "client")}),\n createServerState: () => (${stateObject(ast, "server")}),\n computed: { ${computed} },\n actions: { ${actions} },\n persist: ${persistence},\n lifecycle: { ${lifecycle} },\n});\n\nexport default ${ast.name};\n\nconst __wrnexusRpcContainers = new WeakMap();\nexport const __wrnexusServerFunctions = {\n${rpcWrappers}\n};\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n\n${generateDeclarations(ast)}\n`;
}
/** Standalone browser artifact for an imported `.wrn` store. */
export function generateStoreBrowserModule(ast: PageAst): string {
if (ast.kind !== "global-store" && ast.kind !== "page-store") {
throw new Error("generateStoreBrowserModule requires a store AST");
}
const browserStates = ast.states.filter((state) => state.runtime !== "server");
const stateNames = browserStates.map((state) => state.name);
const safeStateNames = stateNames.filter(safeBinding);
const initialState = `{ ${browserStates.map((state) => `${JSON.stringify(state.name)}: (${state.expr})`).join(", ")} }`;
const computed = ast.computed
.map(
(entry) =>
`${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`,
)
.join(",\n");
const groups = new Map<string, RuntimeFunctionDecl[]>();
for (const fn of ast.runtimeFunctions.filter((entry) =>
["client", "shared", "legacy"].includes(entry.runtime),
)) {
const group = groups.get(fn.name) ?? [];
group.push(fn);
groups.set(fn.name, group);
}
const actions = Array.from(
groups,
([name, functions]) =>
`${JSON.stringify(name)}: [${functions.map((fn) => actionSource(fn, stateNames, true)).join(", ")}]`,
).join(",\n");
const persistence = persistenceSource(ast);
const lifecycleEntries = lifecycleSource(ast, stateNames, true);
return `// generated WRNexusJS browser store module for ${ast.name}
const __root = globalThis;
const __registry = __root.__wrnexusStoreRegistry || (__root.__wrnexusStoreRegistry = new Map());
const __hydrationNode = typeof document !== "undefined" ? document.querySelector("script[data-wrnexus-store-hydration]") : null;
let __hydration = {};
try { __hydration = __hydrationNode ? JSON.parse(__hydrationNode.textContent || "{}") : {}; } catch (_) {}
function __clone(value) { try { return structuredClone(value); } catch (_) { return JSON.parse(JSON.stringify(value)); } }
function __storage(kind) { if (typeof window === "undefined") return null; return kind === "local" ? window.localStorage : kind === "session" ? window.sessionStorage : null; }
function __diagnostic(code, message, details) {
const detail = { code, message, store: ${JSON.stringify(ast.name)}, details };
try { __root.dispatchEvent && __root.dispatchEvent(new CustomEvent("wrnexus:store-diagnostic", { detail })); } catch (_) {}
if (typeof console !== "undefined" && console.warn) console.warn("[wrnexus:store] " + code + ": " + message, details || "");
}
function __csrfToken() {
if (typeof document === "undefined") return undefined;
const match = /(?:^|;\\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie || "");
return match ? decodeURIComponent(match[1]) : undefined;
}
async function __callServerFunction(storeName, functionName, args, options) {
options = options || {};
const csrf = options.csrfToken || __csrfToken();
const traceId = options.traceId || (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
const response = await fetch(options.endpoint || "/__wrnexus/rpc", {
method: "POST",
credentials: "same-origin",
signal: options.signal,
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-wrnexus-csrf": csrf } : {}, options.headers || {}),
body: JSON.stringify({ component: storeName, function: functionName, args: args }),
});
const payload = await response.json().catch(function () { return null; });
if (!response.ok || !payload || !payload.ok) {
const error = new Error(payload && payload.error && payload.error.message || "Server call failed (" + response.status + ")");
error.code = payload && payload.error && payload.error.code || "WRN-RPC-FAILED";
error.status = response.status;
error.details = payload && payload.error && payload.error.details;
error.traceId = payload && payload.error && payload.error.traceId || traceId;
throw error;
}
return payload.value;
}
function __compatible(expected, value) {
if (expected === null || value === null) return expected === value || expected === null;
if (Array.isArray(expected)) return Array.isArray(value);
return typeof expected === typeof value;
}
function __create(definition) {
const routeId = typeof location !== "undefined" ? location.pathname + location.search : "default";
const key = definition.kind === "page" ? definition.name + "@" + routeId : definition.name;
if (__registry.has(key)) return __registry.get(key);
let currentDefinition = definition;
const listeners = new Set();
const initial = currentDefinition.createState();
let restored = null;
if (currentDefinition.persist) {
try {
const storage = __storage(currentDefinition.persist.storage);
const rawValue = storage && storage.getItem("wrnexus:store:" + currentDefinition.name);
const parsed = rawValue ? JSON.parse(rawValue) : null;
if (parsed) {
let candidate = parsed.state;
const fromVersion = Number(parsed.version || 0);
if (fromVersion !== currentDefinition.persist.version) {
if (typeof currentDefinition.persist.migrate === "function") candidate = currentDefinition.persist.migrate(candidate, fromVersion, currentDefinition.persist.version);
else { __diagnostic("WRN-PERSIST-VERSION", "Persisted state version cannot be restored without a migration.", { fromVersion, toVersion: currentDefinition.persist.version }); candidate = null; }
}
if (candidate && typeof currentDefinition.persist.validate === "function") candidate = currentDefinition.persist.validate(candidate);
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
restored = {};
currentDefinition.persist.include.forEach(function (name) { if (Object.prototype.hasOwnProperty.call(candidate, name)) restored[name] = candidate[name]; });
} else if (candidate != null) {
__diagnostic("WRN-PERSIST-INVALID", "Persisted state failed validation and was reset.", candidate);
}
}
} catch (error) { __diagnostic("WRN-PERSIST-RESTORE", "Persisted state could not be restored and was reset.", error); }
}
const raw = Object.assign({}, initial, restored || {}, __hydration[currentDefinition.name] || {});
let mutable = false;
let actionName = "direct";
function persistState() {
if (!currentDefinition.persist) return;
try {
const picked = {};
currentDefinition.persist.include.forEach(function (name) { picked[name] = raw[name]; });
const storage = __storage(currentDefinition.persist.storage);
if (storage) storage.setItem("wrnexus:store:" + currentDefinition.name, JSON.stringify({ version: currentDefinition.persist.version, state: picked }));
} catch (error) { __diagnostic("WRN-PERSIST-WRITE", "Persisted state could not be written.", error); }
}
const state = new Proxy(raw, {
set(target, property, value) {
if (!mutable) throw new TypeError("WRN-STORE-READONLY: " + currentDefinition.name + "." + String(property) + " must be changed by a store action.");
if (Object.is(target[property], value)) return true;
target[property] = value;
persistState();
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: actionName, changed: [String(property)] }); });
return true;
},
deleteProperty(target, property) {
if (!mutable) throw new TypeError("WRN-STORE-READONLY: store state is readonly outside actions");
return Reflect.deleteProperty(target, property);
},
});
const actions = {};
const server = new Proxy({}, { get: function (_target, property) { return function () { return __callServerFunction(currentDefinition.name, String(property), Array.prototype.slice.call(arguments)); }; } });
function installActions() {
Object.keys(actions).forEach(function (name) { delete actions[name]; });
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
const name = pair[0], candidates = pair[1];
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
if (!selected) return;
actions[name] = async function () {
const args = Array.prototype.slice.call(arguments);
const previousMutable = mutable, previousAction = actionName;
mutable = true; actionName = name;
try { return await selected.handler({ state, snapshot: function () { return __clone(state); }, reset: function () { return instance.reset(); }, runtime: "client", routeId, server }, ...args); }
finally { mutable = previousMutable; actionName = previousAction; }
};
});
}
installActions();
const core = {
name: currentDefinition.name,
kind: currentDefinition.kind,
state,
actions,
whenReady: Promise.resolve(),
reset() {
mutable = true; actionName = "$reset";
try {
const next = currentDefinition.createState();
Object.keys(raw).forEach(function (name) { if (!(name in next)) delete raw[name]; });
Object.assign(raw, next); persistState();
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: "$reset", changed: Object.keys(next) }); });
} finally { mutable = false; actionName = "direct"; }
},
snapshot() { return Object.freeze(__clone(raw)); },
subscribe(listener) { listeners.add(listener); return function () { listeners.delete(listener); }; },
async dispose() {
mutable = true; actionName = "$dispose";
try { await currentDefinition.lifecycle && currentDefinition.lifecycle.dispose && currentDefinition.lifecycle.dispose({ state, runtime: "client", routeId, server }); }
finally { mutable = false; actionName = "direct"; listeners.clear(); __registry.delete(key); }
},
async __hotUpdate(nextDefinition) {
const previous = __clone(raw);
const nextShape = nextDefinition.createState();
const preserved = [], reset = [], added = [], removed = [];
Object.keys(previous).forEach(function (name) {
if (!(name in nextShape)) { removed.push(name); return; }
if (__compatible(nextShape[name], previous[name])) { nextShape[name] = previous[name]; preserved.push(name); }
else reset.push(name);
});
Object.keys(nextShape).forEach(function (name) { if (!(name in previous)) added.push(name); });
currentDefinition = nextDefinition;
mutable = true; actionName = "$hmr";
try {
Object.keys(raw).forEach(function (name) { delete raw[name]; });
Object.assign(raw, nextShape);
installActions(); persistState();
} finally { mutable = false; actionName = "direct"; }
const result = { store: currentDefinition.name, preserved, reset, added, removed };
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: "$hmr", changed: added.concat(reset, removed) }); });
try { __root.dispatchEvent && __root.dispatchEvent(new CustomEvent("wrnexus:store-hmr", { detail: result })); } catch (_) {}
return result;
},
};
const instance = new Proxy(core, {
get(target, property, receiver) {
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver);
if (property in actions) return actions[property];
if (property in (currentDefinition.computed || {})) return currentDefinition.computed[property](state);
return state[property];
},
set() { throw new TypeError("WRN-STORE-READONLY: store state is readonly outside actions"); },
});
__registry.set(key, instance);
const hydrationSource = __hydration[currentDefinition.name];
const init = async function () {
const run = async function (name, hook) {
if (!hook) return;
mutable = true; actionName = name;
try { await hook({ state, runtime: "client", routeId, server }); }
finally { mutable = false; actionName = "direct"; }
};
await run("$clientInit", currentDefinition.lifecycle && currentDefinition.lifecycle.clientInit);
if (hydrationSource) await run("$hydrate", currentDefinition.lifecycle && currentDefinition.lifecycle.hydrate);
};
core.whenReady = init();
return instance;
}
if (!__root.__wrnexusApplyStoreHotUpdate) {
__root.__wrnexusApplyStoreHotUpdate = async function (name, definition) {
const results = [];
for (const item of Array.from(__registry.values())) if (item.name === name && typeof item.__hotUpdate === "function") results.push(await item.__hotUpdate(definition));
return results;
};
}
if (!__root.__wrnexusStoreContainer) {
__root.__wrnexusStoreContainer = {
async disposePageStores() { for (const item of Array.from(__registry.values())) if (item.kind === "page") await item.dispose(); },
async hotUpdate(name, definition) { return __root.__wrnexusApplyStoreHotUpdate(name, definition); },
inspect() { return Array.from(__registry.values()).map(function (item) { return { name: item.name, kind: item.kind, state: item.snapshot() }; }); },
};
}
export const ${ast.name}Definition = {
name: ${JSON.stringify(ast.name)},
kind: ${JSON.stringify(ast.storeKind)},
createState: () => (${initialState}),
computed: { ${computed} },
actions: { ${actions} },
persist: ${persistence},
lifecycle: { ${lifecycleEntries} },
};
export const ${ast.name} = __create(${ast.name}Definition);
export default ${ast.name};
`;
}
+30
View File
@@ -0,0 +1,30 @@
import type { PageAst } from "@wrnexus/syntax";
import { createComponentContract } from "./component-contract.ts";
import { generateBrowserModule } from "./client-codegen.ts";
import { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
import { generateDeclarations } from "./type-codegen.ts";
import { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
export interface CompileTargets {
server: string;
browser: string;
declarations: string;
contract: ReturnType<typeof createComponentContract>;
rpc: ReturnType<typeof rpcManifest>;
}
export function generateTargets(ast: PageAst): CompileTargets {
return {
server:
ast.kind === "global-store" || ast.kind === "page-store"
? generateStoreModule(ast)
: generateServerFunctionsModule(ast),
browser:
ast.kind === "global-store" || ast.kind === "page-store"
? generateStoreBrowserModule(ast)
: generateBrowserModule(ast),
declarations: generateDeclarations(ast),
contract: createComponentContract(ast),
rpc: rpcManifest(ast),
};
}
+64
View File
@@ -0,0 +1,64 @@
import type { PageAst } from "@wrnexus/syntax";
function member(name: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
}
function params(astParams: PageAst["runtimeFunctions"][number]["parameters"]): string {
return astParams
.map(
(param) =>
`${member(param.name)}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`,
)
.join(", ");
}
export function generateDeclarations(ast: PageAst): string {
const inline = ast.types
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
if (ast.kind === "global-store" || ast.kind === "page-store") {
const state = ast.states
.filter((entry) => entry.runtime !== "server")
.map((entry) => ` readonly ${member(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const computed = ast.computed
.map((entry) => ` readonly ${member(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const actions = ast.runtimeFunctions
.filter((fn) => fn.runtime !== "server")
.map(
(fn) =>
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}State {\n${state}\n}\n\nexport interface ${ast.name}Computed {\n${computed}\n}\n\nexport interface ${ast.name}Actions {\n${actions}\n}\n\nexport interface ${ast.name}Instance extends ${ast.name}State, ${ast.name}Computed, ${ast.name}Actions {\n reset(): void;\n snapshot(): Readonly<${ast.name}State>;\n}\n\ndeclare const store: ${ast.name}Instance;\nexport default store;\n`;
}
const props = ast.props
.map(
(prop) =>
` readonly ${member(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
)
.join("\n");
const outputs = ast.outputs
.map(
(output) =>
` ${member(output.name)}(${output.payload ? `${member(output.payload.name)}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
)
.join("\n");
const clientFunctions = ast.runtimeFunctions
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
.map(
(fn) =>
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
const serverFunctions = ast.runtimeFunctions
.filter((fn) => fn.runtime === "server")
.map(
(fn) =>
` ${member(fn.name)}(${params(fn.parameters)}): Promise<Awaited<${fn.returnType ?? "unknown"}>>;`,
)
.join("\n");
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}ClientFunctions {\n${clientFunctions}\n}\n\nexport interface ${ast.name}ServerCalls {\n${serverFunctions}\n}\n`;
}
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { generateTargets, parse } from "../src/index.ts";
const ast = parse(`component ConfirmDialog {
props { title: string open: boolean = false }
state { loading: boolean = false }
server state { internalId: string = "secret" }
outputs { confirm(payload: string) close() }
functions {
client async function confirm(value: string): Promise<void> {
const saved = await server.confirm(value)
output.confirm(saved)
}
server async function confirm(value: string): Promise<string> { return value }
shared function normalize(value: string): string { return value.trim() }
}
view { <button>{title}</button> }
}`);
test("emits separate browser and server function targets", () => {
const targets = generateTargets(ast);
expect(targets.browser).toContain('"confirm": async function');
expect(targets.browser).toContain('"normalize": function');
expect(targets.server).toContain("async function confirm");
expect(targets.rpc).toEqual([
expect.objectContaining({ component: "ConfirmDialog", function: "confirm" }),
]);
expect(targets.declarations).toContain("interface ConfirmDialogOutputs");
expect(targets.declarations).toContain("interface ConfirmDialogServerCalls");
});
test("component contracts retain declared union options", () => {
const targets = generateTargets(
parse(`component SizeBox {
props { size: "small" | "medium" | "large" = "medium" }
view { <div></div> }
}`),
);
expect(targets.contract.props[0]?.options).toEqual(["small", "medium", "large"]);
});
test("generates a standalone browser module for imported stores", () => {
const targets = generateTargets(
parse(`page store SearchStore {
state { query: string = "" }
client state { focused: boolean = false }
server state { secret: string = "hidden" }
computed { empty: boolean = query.length === 0 }
functions { client function setQuery(value: string): void { query = value } }
persist { storage = "session" include = ["query"] version = 1 }
}`),
);
expect(targets.browser).toContain("__wrnexusStoreRegistry");
expect(targets.browser).toContain('name: "SearchStore"');
expect(targets.browser).toContain('"query"');
expect(targets.browser).not.toContain('"secret": ("hidden")');
expect(targets.declarations).toContain("interface SearchStoreInstance");
});
test("browser codegen avoids reserved prop bindings and parameter collisions", () => {
const targets = generateTargets(
parse(`component ReservedBindings {
props { class: string = "" output: string = "" }
state { value: string = "" }
outputs { change(payload: { value: string }) }
functions {
client function update(output: string): void {
value = output
}
client function notify(): void {
output.change({ value: value })
}
}
view { <button>{class}</button> }
}`),
);
expect(targets.browser).not.toContain("const { class }");
expect(targets.browser).not.toContain("const output = context.output;\n const output");
expect(() => new Function(targets.browser.replace(/^export\s+/gm, ""))).not.toThrow();
});
test("RPC manifests expose only server functions referenced through server.name", () => {
const targets = generateTargets(
parse(`component SecureActions {
functions {
client async function saveClient(): Promise<void> { await server.save("ok") }
server async function save(value: string): Promise<string> { return value }
server function internalSecret(): string { return "secret" }
}
view { <button @click='saveClient()'>Save</button> }
}`),
);
expect(targets.rpc.map((entry) => entry.function)).toEqual(["save"]);
expect(targets.server).toContain("internalSecret");
});
test("generated browser stores bind typed RPC, persistence validation, and HMR", () => {
const targets = generateTargets(
parse(`global store UserStore {
state { user: string | null = null count: number = 0 }
functions {
client async function refresh(): Promise<void> { user = await server.loadCurrentUser() }
server async function loadCurrentUser(): Promise<string | null> { return "Ajay" }
server function internalOnly(): string { return "secret" }
}
persist {
storage = "local"
include = ["count"]
version = 2
migrations { function migrate(value, fromVersion, toVersion) { return value } }
validate { function validate(value) { return value && typeof value === "object" ? value : null } }
}
}`),
);
expect(targets.browser).toContain("const server = context.server");
expect(targets.browser).toContain("currentDefinition.persist.migrate");
expect(targets.browser).toContain("currentDefinition.persist.validate");
expect(targets.browser).toContain("__wrnexusApplyStoreHotUpdate");
expect(targets.rpc.map((entry) => entry.function)).toEqual(["loadCurrentUser"]);
});
test("browser codegen binds peer client functions through the scoped function table", () => {
const targets = generateTargets(
parse(`component PeerCalls {
state { value: number = 0 }
functions {
client function increment(): void { value += 1 }
client function run(): void { increment() }
}
view { <button @click='run()'>{value}</button> }
}`),
);
expect(targets.browser).toContain("context.functions");
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`,
)() as { bindClientScope: (context: Record<string, unknown>) => Record<string, () => void> };
const state = { value: 0 };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
functions.run?.();
expect(state.value).toBe(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+23
View File
@@ -0,0 +1,23 @@
export interface ClientModuleScope {
output: Record<string, (payload?: unknown) => void>;
server: Record<string, (...args: unknown[]) => Promise<unknown>>;
props: Readonly<Record<string, unknown>>;
refs: Record<string, Element>;
}
const moduleCache = new Map<string, Promise<any>>();
export async function loadClientFunctions(
url: string,
scope: ClientModuleScope,
): Promise<Record<string, (...args: unknown[]) => unknown>> {
const module = await (moduleCache.get(url) ??
(() => {
const promise = import(/* @vite-ignore */ url);
moduleCache.set(url, promise);
return promise;
})());
if (typeof module.bindClientScope === "function") return module.bindClientScope(scope);
return module.__wrnexusClientFunctions ?? {};
}
export function invalidateClientModule(url: string): void {
moduleCache.delete(url);
}
+6
View File
@@ -29,3 +29,9 @@ export function getNavRuntime(): string {
export function getRealtimeRuntime(): string {
return REALTIME_RUNTIME;
}
export * from "./outputs.ts";
export * from "./server-client.ts";
export * from "./refs.ts";
export * from "./client-functions.ts";
export type * from "./types.ts";
+10
View File
@@ -243,6 +243,16 @@ export const NAV_RUNTIME = String.raw`
*/
function dispose(root) {
unmountPackageRuntimes(root);
try {
var stores = window.__wrnexusStoreContainer;
if (stores && typeof stores.disposePageStores === "function") {
Promise.resolve(stores.disposePageStores()).catch(function (error) {
console.error("[wrnexus] failed to dispose page stores", error);
});
}
} catch (error) {
console.error("[wrnexus] failed to access page stores", error);
}
try {
if (
typeof window.__wrnexusDisposeBehaviors ===
+39
View File
@@ -0,0 +1,39 @@
export type OutputHandler<T = unknown> = (payload: T) => void | Promise<void>;
export interface OutputHost extends HTMLElement {
__wrnexusOutputHandlers?: Map<string, Set<OutputHandler>>;
}
export function registerOutputHandler<T>(
host: OutputHost,
name: string,
handler: OutputHandler<T>,
): () => void {
const registry = (host.__wrnexusOutputHandlers ??= new Map());
const handlers = registry.get(name) ?? new Set();
handlers.add(handler as OutputHandler);
registry.set(name, handlers);
return () => {
handlers.delete(handler as OutputHandler);
if (!handlers.size) registry.delete(name);
};
}
export async function invokeOutput<T>(host: OutputHost, name: string, payload?: T): Promise<void> {
const handlers = host.__wrnexusOutputHandlers?.get(name);
if (handlers?.size) {
for (const handler of handlers) await handler(payload);
return;
}
// Compatibility path for legacy listeners outside a hydrated WRN parent.
host.dispatchEvent(new CustomEvent(name, { detail: payload }));
host.dispatchEvent(new CustomEvent(`wrnexus:${name}`, { detail: payload }));
}
export function createOutputProxy<T extends Record<string, (...args: any[]) => void>>(
host: OutputHost,
): T {
return new Proxy({} as T, {
get: (_target, property) => (payload?: unknown) =>
invokeOutput(host, String(property), payload),
});
}
+147 -2
View File
@@ -24,6 +24,7 @@ export const REACTIVE_RUNTIME = String.raw`
var pendingUpdateHooks = new Map();
var updateHooksScheduled = false;
var behaviorObserver;
var clientModuleCache = new Map();
function reportDiagnostic(code, message, element, detail) {
var payload = {
@@ -478,6 +479,21 @@ export const REACTIVE_RUNTIME = String.raw`
}
}
function loadClientModule(element) {
var url = element.getAttribute("data-wrn-client-module");
if (!url) return Promise.resolve(null);
var promise = clientModuleCache.get(url);
if (!promise) {
promise = import(url).catch(function (error) {
clientModuleCache.delete(url);
reportDiagnostic("WRN-CLIENT-MODULE", "Failed to load browser function module '" + url + "'.", element, error);
return null;
});
clientModuleCache.set(url, promise);
}
return promise;
}
function setupScope(el) {
if (el.__wrnexusScope) return;
@@ -677,13 +693,39 @@ export const REACTIVE_RUNTIME = String.raw`
}
var behaviorFunctions = {};
var componentEventTarget = el.querySelector("[data-wrn-events]") || el;
var moduleBindings = {};
var componentEventTarget = el.hasAttribute("data-wrn-events")
? el
: el.querySelector("[data-wrn-events]") || el;
var declaredEvents = new Set(
String(componentEventTarget.getAttribute("data-wrn-events") || "")
.split(",")
.map(function (name) { return name.trim(); })
.filter(Boolean),
);
var outputHandlers = componentEventTarget.__wrnexusOutputHandlers || (componentEventTarget.__wrnexusOutputHandlers = {});
var outputProxy = new Proxy({}, {
get: function (_target, property) {
return function (payload) {
return invokeComponentOutput(componentEventTarget, String(property), payload);
};
},
});
var componentRpcName = componentEventTarget.getAttribute("data-wrn-component") || componentEventTarget.getAttribute("data-wrn-hydration") || "component";
var serverProxy = new Proxy({}, {
get: function (_target, property) {
return function () {
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
};
},
});
var propsProxy = new Proxy({}, {
get: function (_target, property) { return peekScope(String(property)); },
set: function () { throw new TypeError("WRN-PROP-READONLY: props are readonly"); },
});
var refsProxy = new Proxy({}, {
get: function (_target, property) { return el.querySelector('[data-ref="' + String(property).replace(/"/g, '\"') + '"]'); },
});
var stateWatchers = {};
var anyStateListeners = new Set();
var cleanupCallbacks = [];
@@ -695,6 +737,11 @@ export const REACTIVE_RUNTIME = String.raw`
return dispatchComponentEvent(componentEventTarget, name, detail);
};
}
if (name === "output") return outputProxy;
if (name === "server") return serverProxy;
if (name === "props") return propsProxy;
if (name === "refs") return refsProxy;
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
if (name === "$emit") {
return function (eventName, detail) {
return dispatchComponentEvent(componentEventTarget, eventName, detail);
@@ -919,6 +966,42 @@ export const REACTIVE_RUNTIME = String.raw`
});
}
function installClientModule(module) {
if (!module) return;
var stateProxy = new Proxy({}, {
get: function (_target, property) { return readScope(String(property)); },
set: function (_target, property, value) { writeScope(String(property), value); return true; },
ownKeys: function () { return Object.keys(signals); },
getOwnPropertyDescriptor: function () { return { enumerable: true, configurable: true }; },
});
var context = {
state: stateProxy,
output: outputProxy,
server: serverProxy,
props: propsProxy,
refs: refsProxy,
};
var importedBindings = module.__wrnexusImportedBindings;
if (importedBindings && typeof importedBindings === "object") {
Object.keys(importedBindings).forEach(function (name) {
var binding = importedBindings[name];
moduleBindings[name] = binding;
if (binding && typeof binding.subscribe === "function") {
cleanupCallbacks.push(binding.subscribe(function () { renderAll(); }));
}
});
}
var functions = typeof module.bindClientScope === "function"
? module.bindClientScope(context)
: module.__wrnexusClientFunctions;
if (!functions || typeof functions !== "object") return;
Object.keys(functions).forEach(function (name) {
if (typeof functions[name] === "function") behaviorFunctions[name] = functions[name];
});
}
installClientModule(el.__wrnexusClientModule);
// A binding belongs to THIS scope only when el is the node's nearest
// [data-scope] ancestor. Otherwise a nested scope owns it and we skip it,
// so an outer scope never clobbers an inner one's values.
@@ -1233,6 +1316,7 @@ export const REACTIVE_RUNTIME = String.raw`
eventLocals.event = event;
eventLocals.$event = event;
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -1773,6 +1857,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event;
locals.$event = event;
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -1794,6 +1879,19 @@ export const REACTIVE_RUNTIME = String.raw`
? { passive: true }
: undefined;
if (target === componentEventTarget && declaredEvents.has(evt)) {
var directHandler = function (payload) {
var locals = decodeLoopLocals(node);
locals.payload = payload;
locals.event = undefined;
locals.$event = undefined;
return runStmt(stmt, locals);
};
(outputHandlers[evt] || (outputHandlers[evt] = new Set())).add(directHandler);
cleanupCallbacks.push(function () {
if (outputHandlers[evt]) outputHandlers[evt].delete(directHandler);
});
}
target.addEventListener(evt, listener, options);
cleanupCallbacks.push(function () {
target.removeEventListener(evt, listener, options);
@@ -1932,7 +2030,16 @@ export const REACTIVE_RUNTIME = String.raw`
function hydrate() {
if (element.__wrnexusScope || !element.isConnected) return;
setupScope(element);
var moduleUrl = element.getAttribute("data-wrn-client-module");
if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__") {
setupScope(element);
return;
}
loadClientModule(element).then(function (module) {
if (element.__wrnexusScope || !element.isConnected) return;
element.__wrnexusClientModule = module;
setupScope(element);
});
}
if (strategy === "load") {
@@ -2616,6 +2723,43 @@ export const REACTIVE_RUNTIME = String.raw`
}
}
function invokeComponentOutput(root, name, payload) {
if (!root || !name) return undefined;
var registry = root.__wrnexusOutputHandlers;
var handlers = registry && registry[name];
if (handlers && handlers.size) {
var values = [];
handlers.forEach(function (handler) { values.push(handler(payload)); });
return values.some(function (value) { return value && typeof value.then === "function"; })
? Promise.all(values)
: values[values.length - 1];
}
return dispatchComponentEvent(root, name, payload);
}
function callServerFunction(component, functionName, args) {
var csrf = document.querySelector('meta[name="wrnexus-csrf"]');
return fetch("/__wrnexus/rpc", {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
"x-wrnexus-csrf": csrf ? csrf.getAttribute("content") || "" : "",
},
body: JSON.stringify({ component: component, function: functionName, args: args || [] }),
}).then(function (response) {
return response.json().catch(function () { return null; }).then(function (payload) {
if (!response.ok || !payload || !payload.ok) {
var error = new Error(payload && payload.error && payload.error.message || "Server function call failed");
error.code = payload && payload.error && payload.error.code || "WRN-RPC-FAILED";
error.status = response.status;
throw error;
}
return payload.value;
});
});
}
function dispatchComponentEvent(root, name, detail) {
if (!root || !name) return null;
var EventConstructor =
@@ -3789,6 +3933,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
window.__wrnexusHydrateScopes = hydrateScopes;
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
window.__wrnexusDisposeBehaviors = disposeBehaviors;
if (document.readyState === "loading") {
+10
View File
@@ -0,0 +1,10 @@
export function collectRefs(root: ParentNode): Record<string, Element> {
const refs: Record<string, Element> = {};
if (root instanceof Element && root.hasAttribute("data-ref"))
refs[root.getAttribute("data-ref")!] = root;
root.querySelectorAll("[data-ref]").forEach((element) => {
const name = element.getAttribute("data-ref");
if (name) refs[name] = element;
});
return refs;
}
+63
View File
@@ -0,0 +1,63 @@
export interface ServerCallOptions {
endpoint?: string;
signal?: AbortSignal;
headers?: HeadersInit;
csrfToken?: string;
}
export class WrnServerCallError extends Error {
constructor(
message: string,
readonly code: string,
readonly status: number,
readonly details?: unknown,
) {
super(message);
}
}
function csrfFromCookie(): string | undefined {
if (typeof document === "undefined") return undefined;
const raw = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie)?.[1];
return raw ? decodeURIComponent(raw) : undefined;
}
export async function callServerFunction<TInput extends unknown[], TOutput>(
component: string,
functionName: string,
args: TInput,
options: ServerCallOptions = {},
): Promise<TOutput> {
const csrfToken = options.csrfToken ?? csrfFromCookie();
const response = await fetch(options.endpoint ?? "/__wrnexus/rpc", {
method: "POST",
credentials: "same-origin",
signal: options.signal,
headers: {
"content-type": "application/json",
...(csrfToken ? { "x-wrnexus-csrf": csrfToken } : {}),
...options.headers,
},
body: JSON.stringify({ component, function: functionName, args }),
});
const payload = (await response.json().catch(() => null)) as any;
if (!response.ok || !payload?.ok)
throw new WrnServerCallError(
payload?.error?.message ?? `Server call failed (${response.status})`,
payload?.error?.code ?? "WRN-RPC-FAILED",
response.status,
payload?.error?.details,
);
return payload.value as TOutput;
}
export function createServerProxy<T extends Record<string, (...args: any[]) => Promise<any>>>(
component: string,
options: ServerCallOptions = {},
): T {
return new Proxy({} as T, {
get:
(_target, property) =>
(...args: unknown[]) =>
callServerFunction(component, String(property), args, options),
});
}
+11
View File
@@ -0,0 +1,11 @@
export interface HydrationScopeApi {
get(name: string): unknown;
set(name: string, value: unknown): void;
call(name: string, ...args: unknown[]): unknown;
snapshot(): Readonly<Record<string, unknown>>;
dispose(): void;
}
export interface WrnexusBrowserGlobals {
__wrnexusHydrateScopes?(root?: ParentNode): void;
__wrnexusDisposeBehaviors?(root?: ParentNode): void;
}
-1
View File
@@ -334,7 +334,6 @@ test("data-for preserves arrays of objects from encoded component scope", () =>
]);
});
test("$emit inside component functions does not depend on a global browser event", () => {
const behavior = Buffer.from(
JSON.stringify({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -21,6 +21,7 @@
"@wrnexus/db": "workspace:*",
"@wrnexus/pubsub": "workspace:*",
"@wrnexus/uploader": "workspace:*",
"@wrnexus/plugin": "workspace:*"
"@wrnexus/plugin": "workspace:*",
"@wrnexus/store": "workspace:*"
}
}
+4
View File
@@ -25,6 +25,7 @@ import type { Mode } from "@wrnexus/core";
import type { AssetServer } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
import { serveWrnBrowserArtifact } from "./pipeline.ts";
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
export interface DevStyles {
@@ -84,6 +85,9 @@ export function createDevAssetServer(
},
async serve(pathname: string): Promise<Response | null> {
if (pathname.startsWith("/__wrnexus/client/")) {
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime());
if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime());
if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime());
+122 -4
View File
@@ -32,7 +32,15 @@ import {
import { connectFromConfig } from "@wrnexus/db/connect";
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { invalidateModule, loadModule, setCompileCacheDir } from "./pipeline.ts";
import {
invalidateModule,
loadModule,
loadWrnServerModule,
setCompileCacheDir,
setCompileImportOptions,
wrnBrowserArtifactUrl,
} from "./pipeline.ts";
import { createRpcHandler } from "@wrnexus/ssr/rpc";
import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts";
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
@@ -173,6 +181,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const appDir = resolve(opts.appDir);
const appRoot = dirname(appDir);
const mode: Mode = opts.mode ?? "development";
const importConfig = (opts.appConfig?.imports ?? {}) as {
mode?: "legacy" | "compatible" | "explicit";
autoImport?: boolean;
aliases?: Record<string, string>;
};
setCompileImportOptions(appRoot, importConfig);
const configuredPlugins = opts.plugins ?? (opts.appConfig?.plugins as PluginInput | undefined);
const discoveredPlugins = await discoverPlugins(appRoot, configuredPlugins, {
@@ -384,8 +398,33 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
config: devToolbarConfig,
collector: devToolbarCollector,
root: appRoot,
panels: pluginToolbarPanels,
panels: [
{
id: "runtime",
title: "Runtime",
icon: "cpu",
description:
"Client, server, and shared functions, hydration modules, and runtime-boundary diagnostics.",
order: 10,
},
{
id: "stores",
title: "Stores",
icon: "database",
description:
"Global/page stores, safe client state, computed values, actions, persistence, and hydration.",
order: 20,
},
...pluginToolbarPanels,
],
platform: {
wrnexus060: {
clientFunctions: true,
serverFunctions: true,
sharedFunctions: true,
typedOutputs: true,
requestScopedStores: true,
},
plugins: pluginRunner.plugins.map((plugin) => ({
name: plugin.name,
version: plugin.version,
@@ -413,13 +452,59 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
: undefined,
};
const handlers = createHandlers(runtimeDeps);
const rpcHandler = createRpcHandler({
async resolve(rawComponent) {
const componentName = rawComponent.split(":", 1)[0] ?? rawComponent;
const preferred = router.components.find(
(entry) => entry.name.toLowerCase() === componentName.toLowerCase(),
);
const candidates = [
...(preferred ? [preferred.file] : []),
...router.pages.filter((route) => route.file.endsWith(".wrn")).map((route) => route.file),
...router.layouts.map((entry) => entry.file),
...router.stores.map((entry) => entry.file),
...router.components.filter((entry) => entry !== preferred).map((entry) => entry.file),
];
for (const file of new Set(candidates)) {
const module = await loadWrnServerModule(file);
const functions = module.__wrnexusServerFunctions;
const manifest = module.__wrnexusRpcManifest;
if (!functions || typeof functions !== "object" || !Array.isArray(manifest)) continue;
const ownsComponent = manifest.some(
(entry: any) =>
String(entry?.component ?? "").toLowerCase() === componentName.toLowerCase(),
);
if (!ownsComponent) continue;
return {
functions: functions as Record<string, (...args: any[]) => any>,
manifest: manifest as any,
};
}
return null;
},
validateCsrf(request) {
const url = new URL(request.url);
const origin = request.headers.get("origin");
if (origin && origin !== url.origin) return false;
const cookieToken = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(
request.headers.get("cookie") ?? "",
)?.[1];
return (
!cookieToken ||
decodeURIComponent(cookieToken) === (request.headers.get("x-wrnexus-csrf") ?? "")
);
},
});
const server = Bun.serve<WsData>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 10 * 1024 * 1024,
fetch: handlers.fetch,
fetch(request, server) {
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
return handlers.fetch(request, server);
},
websocket: handlers.websocket,
});
@@ -473,7 +558,40 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
}
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
hub.reload();
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
for (const changed of files) {
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
if (!absolute.endsWith(".wrn")) continue;
try {
const source = readFileSync(absolute, "utf8");
const declaration = /\b(global|page)\s+store\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source);
if (!declaration) continue;
storeUpdates.push({
name: declaration[2]!,
kind: declaration[1]!,
url: wrnBrowserArtifactUrl(absolute),
});
} catch (error) {
console.warn(`[wrnexus] failed to prepare store HMR for ${absolute}`, error);
}
}
if (storeUpdates.length) {
hub.broadcastJson({ type: "store-update", version: Date.now(), stores: storeUpdates });
}
const onlyStores =
storeUpdates.length > 0 &&
files.every((changed) => {
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
if (!absolute.endsWith(".wrn")) return false;
try {
return /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(
readFileSync(absolute, "utf8"),
);
} catch {
return false;
}
});
if (!onlyStores) hub.reload();
hub.broadcastJson({
channel: "toolbar",
type: "toolbar:scan",
+214 -21
View File
@@ -12,9 +12,10 @@ import {
mkdirSync,
statSync,
unlinkSync,
existsSync,
} from "node:fs";
import { dirname, join, basename, extname } from "node:path";
import { compileWireFile } from "@wrnexus/compiler";
import { dirname, join, basename, extname, resolve } from "node:path";
import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler";
import type { Context, Middleware } from "@wrnexus/core";
/**
@@ -48,13 +49,105 @@ export function runMiddleware(
*/
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
const moduleVersions = new Map<string, number>();
const browserArtifactPaths = new Map<string, string>();
type ImportMode = "legacy" | "compatible" | "explicit";
interface CompileImportOptions {
mode: ImportMode;
aliases: Record<string, string>;
autoImport: boolean;
}
const compileImportOptions = new Map<string, CompileImportOptions>();
const warnedImportDiagnostics = new Set<string>();
export function setCompileImportOptions(
appRoot: string,
options: { mode?: ImportMode; aliases?: Record<string, string>; autoImport?: boolean } = {},
): void {
compileImportOptions.set(resolve(appRoot), {
mode: options.mode ?? "compatible",
aliases: { "@": "./app", ...(options.aliases ?? {}) },
autoImport: options.autoImport ?? true,
});
}
const compileInProgress = new Map<string, WrnCompileArtifacts>();
function projectRootForFile(file: string): string {
let current = dirname(resolve(file));
while (true) {
if (existsSync(join(current, "app"))) return current;
const parent = dirname(current);
if (parent === current) return dirname(resolve(file));
current = parent;
}
}
function rewriteArtifactImports(
code: string,
ast: PageAst,
importer: string,
target: "main" | "server" | "browser",
): string {
if (!ast.structuredImports.length) return code;
const root = projectRootForFile(importer);
const importOptions = compileImportOptions.get(resolve(root)) ?? {
mode: "compatible" as const,
aliases: { "@": "./app" },
autoImport: true,
};
const resolved = resolveWrnImports(ast.structuredImports, importer, {
appRoot: root,
mode: importOptions.mode,
aliases: importOptions.aliases,
});
let output = code;
for (const entry of resolved) {
if (entry.diagnostic) {
const key = `${importer}:${entry.diagnostic.code}:${entry.declaration.source}`;
if (entry.diagnostic.severity === "error") {
throw new Error(`${entry.diagnostic.code}: ${entry.diagnostic.message}`);
}
if (!warnedImportDiagnostics.has(key)) {
warnedImportDiagnostics.add(key);
console.warn(`[wrnexus] ${entry.diagnostic.code}: ${entry.diagnostic.message}`);
}
}
if (!entry.resolved || !entry.declaration.source) continue;
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
continue;
let replacement = entry.resolved;
if (entry.resolved.endsWith(".wrn")) {
const dependencySource = readFileSync(entry.resolved, "utf8");
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
const dependency = compileWireArtifacts(
entry.resolved,
moduleVersions.get(entry.resolved) ?? 0,
);
if (target === "browser") {
if (!isStore) {
// Components and layouts are compile-time dependencies in browser modules.
output = output.replace(entry.declaration.raw, "");
continue;
}
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
} else {
replacement = target === "server" ? dependency.server : dependency.main;
}
}
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
}
return output;
}
export function loadModule(file: string): Promise<Record<string, unknown>> {
let mod = moduleCache.get(file);
if (!mod) {
const version = moduleVersions.get(file) ?? 0;
// `.wrn` files are compiled to TypeScript first, then imported.
let target = file.endsWith(".wrn") ? compileWireToTs(file, version) : file;
let target = file.endsWith(".wrn") ? compileWireArtifacts(file, version).main : file;
let temporary = false;
// Bun intentionally caches local TS/JS modules by filesystem path and ignores
// URL query strings. A short-lived versioned sibling keeps relative imports
@@ -116,31 +209,131 @@ function hashPath(s: string): string {
* components) share one cache dir without colliding. Generated modules are
* self-contained (no relative imports), so the cache location doesn't affect them.
*/
function compileWireToTs(file: string, version = 0): string {
function importedValueBindings(ast: PageAst): Set<string> {
const names = new Set<string>();
for (const entry of ast.structuredImports) {
if (entry.typeOnly) continue;
if (entry.defaultImport) names.add(entry.defaultImport);
if (entry.namespaceImport) names.add(entry.namespaceImport);
for (const item of entry.namedImports) if (!item.typeOnly) names.add(item.local);
}
return names;
}
function validateConfiguredImports(source: string, ast: PageAst, file: string): void {
const root = projectRootForFile(file);
const options = compileImportOptions.get(resolve(root));
if (!options || options.mode === "legacy") return;
const imported = importedValueBindings(ast);
const usedComponents = new Set(
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
);
const missing = [...usedComponents].filter((name) => !imported.has(name));
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
if (!missing.length) return;
const unique = [...new Set(missing)];
const message = `WRN-IMPORT-IMPLICIT: ${file} uses ${unique.join(", ")} without explicit imports.`;
if (options.mode === "explicit") throw new Error(message);
const key = `${file}:WRN-IMPORT-IMPLICIT:${unique.join(",")}`;
if (!warnedImportDiagnostics.has(key)) {
warnedImportDiagnostics.add(key);
console.warn(`[wrnexus] ${message}`);
}
}
export interface WrnCompileArtifacts {
main: string;
browser: string;
server: string;
declarations: string;
contract: string;
rpc: string;
}
export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts {
const active = compileInProgress.get(file);
if (active) return active;
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
const name = basename(file).replace(/\.wrn$/, "");
const suffix = version ? `-hmr-${version}` : "";
const source = readFileSync(file, "utf8");
// Include the source contents in the cache identity. Package managers, git
// checkouts, archive extraction, and linked dependencies can all replace a
// file while preserving (or moving backwards) its mtime. An mtime-only cache
// then serves an older compiled component even across a clean build.
const out = join(
cacheDir,
`${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}.wrn.ts`,
);
// The content hash makes this safe even when source timestamps are preserved.
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}`;
const artifacts: WrnCompileArtifacts = {
main: join(cacheDir, `${stem}.wrn.ts`),
browser: join(cacheDir, `${stem}.client.mjs`),
server: join(cacheDir, `${stem}.server.ts`),
declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`),
};
compileInProgress.set(file, artifacts);
try {
if (statSync(out).isFile()) return out;
} catch {
/* cache missing → compile below */
try {
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
return artifacts;
}
} catch {
// Compile missing artifact set below.
}
const result = compile(source, file);
validateConfiguredImports(source, result.ast, file);
const targets = generateTargets(result.ast);
mkdirSync(cacheDir, { recursive: true });
const browserPath = `/__wrnexus/client/${stem}.mjs`;
const mainCode = rewriteArtifactImports(
result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath),
result.ast,
file,
"main",
);
writeFileSync(artifacts.main, mainCode, "utf8");
writeFileSync(
artifacts.browser,
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
"utf8",
);
browserArtifactPaths.set(browserPath, artifacts.browser);
writeFileSync(
artifacts.server,
rewriteArtifactImports(targets.server, result.ast, file, "server"),
"utf8",
);
writeFileSync(artifacts.declarations, targets.declarations, "utf8");
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
return artifacts;
} finally {
compileInProgress.delete(file);
}
}
const code = compileWireFile(source, file);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(out, code, "utf8");
return out;
export async function loadWrnServerModule(file: string): Promise<Record<string, unknown>> {
const version = moduleVersions.get(file) ?? 0;
const artifact = compileWireArtifacts(file, version).server;
return import(pathToFileURL(artifact).href) as Promise<Record<string, unknown>>;
}
export function wrnBrowserArtifact(file: string): string {
return compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
}
export function wrnBrowserArtifactUrl(file: string): string {
const artifact = compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
}
export function serveWrnBrowserArtifact(pathname: string): Response | null {
const artifact = browserArtifactPaths.get(pathname);
if (!artifact || !existsSync(artifact)) return null;
return new Response(readFileSync(artifact, "utf8"), {
headers: {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-store, max-age=0",
pragma: "no-cache",
expires: "0",
},
});
}
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
+1
View File
@@ -203,6 +203,7 @@ function buildProdRouter(manifest: ProdManifest): {
middlewareFiles: [],
components: manifest.components.map((c) => ({ name: c.name, file: c.name })),
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
stores: [],
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
matchPage: (p) => matchRoute(pages, p),
matchApi: (p) => matchRoute(api, p),
+48 -4
View File
@@ -37,6 +37,12 @@ import {
} from "@wrnexus/core";
import type { Router } from "@wrnexus/router";
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
import {
disposeRequestStores,
renderStoreHydration,
requestStoreContainer,
} from "@wrnexus/ssr/store-context";
import type { StoreDefinition } from "@wrnexus/store";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
import {
@@ -549,6 +555,8 @@ export const HMR_CLIENT_JS = `
} else if (msg.type === "css") {
swapCss(msg.version);
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: msg }));
} else if (msg.type === "store-update") {
applyStoreUpdates(msg);
} else if (msg.type === "reload") requestSync();
else if (msg.type === "html") applyHtml(msg.html);
else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message);
@@ -563,6 +571,28 @@ export const HMR_CLIENT_JS = `
return true;
}
async function applyStoreUpdates(message) {
var updates = Array.isArray(message.stores) ? message.stores : [];
for (var i = 0; i < updates.length; i++) {
var update = updates[i];
try {
var separator = String(update.url).indexOf("?") >= 0 ? "&" : "?";
var module = await import(String(update.url) + separator + "hmr=" + encodeURIComponent(String(message.version || Date.now())));
var definition = module[String(update.name) + "Definition"];
if (!definition) throw new Error("Generated store module did not export its definition");
if (typeof window.__wrnexusApplyStoreHotUpdate === "function") {
var result = await window.__wrnexusApplyStoreHotUpdate(String(update.name), definition);
window.dispatchEvent(new CustomEvent("wrnexus:hmr-store-updated", { detail: { update: update, result: result } }));
}
} catch (error) {
console.error("[wrnexus] store HMR update failed", update, error);
requestSync();
return;
}
}
}
function requestSync() {
if (pendingSync) return;
pendingSync = true;
@@ -1311,6 +1341,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// Issue the CSRF token cookie so forms on this page can echo it back.
csrfToken(ctx);
const storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
const mod = await loadModule(matched.route.file);
const component = mod.default;
@@ -1322,8 +1353,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
const meta = (mod.meta ?? {}) as PageMeta;
const pageCtx = ctx as Context & {
__wrnexusCallApi?: (path: string, method?: string) => Promise<unknown>;
__wrnexusUseStore?: (definition: StoreDefinition<any, any, any>) => Promise<unknown>;
};
pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method);
pageCtx.__wrnexusUseStore = (definition) => storeContainer.use(definition);
let body = await renderComponents(String(await component(pageCtx)), ctx.t);
const resolvedTheme = deps.theme
? resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)
@@ -1334,15 +1367,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// Page layout: a page selects one by exporting `layout = "<name>"`
// (app/layouts/<name>.wrn), else falls back to a `default` layout if one
// exists. `layout = "none"` opts out. The layout wraps the body via <slot>.
const importedLayout =
mod.layout && typeof mod.layout === "object"
? (mod.layout as { name?: string; render?: (props: Record<string, unknown>) => string })
: undefined;
const layoutName =
(isMobileRequest && deps.mobile?.layout
? deps.mobile.layout
: typeof mod.layout === "string"
? mod.layout
: undefined) ?? "default";
const layout =
layoutName === "none" ? undefined : router.layouts.find((l) => l.name === layoutName);
if (layout) {
: importedLayout?.name) ?? "default";
const layout = importedLayout
? undefined
: layoutName === "none"
? undefined
: router.layouts.find((l) => l.name === layoutName);
if (importedLayout?.render) {
body = await renderComponents(fillSlots(String(importedLayout.render({})), body), ctx.t);
} else if (layout) {
try {
const layoutMod = await loadModule(layout.file);
const layoutRender = (layoutMod as { render?: (p: Record<string, string>) => string })
@@ -1451,6 +1493,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
.join("\n "),
extraBody:
[
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
]
@@ -1464,6 +1507,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// the shell carries a per-request CSP nonce in dev, which would otherwise make
// the ETag change every request. Same content → same ETag → 304 on revalidate.
const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`);
await disposeRequestStores(ctx.req);
const method = ctx.req.method.toUpperCase();
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
return new Response(null, {
@@ -0,0 +1,55 @@
import { afterEach, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
compileWireArtifacts,
setCompileCacheDir,
setCompileImportOptions,
} from "../src/pipeline.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function fixture(): { root: string; page: string } {
const root = mkdtempSync(join(tmpdir(), "wrn-import-mode-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/components"), { recursive: true });
writeFileSync(
join(root, "app/components/Card.wrn"),
"component Card { view { <div>Card</div> } }",
);
const page = join(root, "app/pages/index.wrn");
writeFileSync(page, "page Home { view { <Card /> } }");
setCompileCacheDir(join(root, ".wrnexus"));
return { root, page };
}
test("legacy, compatible, and explicit import modes are enforced from app config", () => {
const { root, page } = fixture();
setCompileImportOptions(root, { mode: "legacy" });
expect(() => compileWireArtifacts(page, 1)).not.toThrow();
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.join(" "));
try {
setCompileImportOptions(root, { mode: "compatible" });
expect(() => compileWireArtifacts(page, 2)).not.toThrow();
expect(warnings.some((message) => message.includes("WRN-IMPORT-IMPLICIT"))).toBe(true);
setCompileImportOptions(root, { mode: "explicit" });
expect(() => compileWireArtifacts(page, 3)).toThrow("WRN-IMPORT-IMPLICIT");
writeFileSync(
page,
'import Card from "@/components/Card.wrn"\npage Home { view { <Card /> } }',
);
expect(() => compileWireArtifacts(page, 4)).not.toThrow();
} finally {
console.warn = originalWarn;
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"sideEffects": false,
+12 -5
View File
@@ -1,7 +1,7 @@
export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
if (window.__wrnexusDevToolbar) return;
const KEY = "__wrnexus_dev_toolbar_settings__";
const state = { open:false, issues:[], report:null, search:"", severity:"all", config:{ position:"bottom-center", scanOnNavigation:true, scanOnHmr:true } };
const state = { open:false, issues:[], report:null, search:"", severity:"all", category:"all", platform:null, panels:[], config:{ position:"bottom-center", scanOnNavigation:true, scanOnHmr:true } };
try { state.config = Object.assign(state.config, JSON.parse(localStorage.getItem(KEY) || "{}")); } catch {}
const host = document.createElement("wrnexus-dev-toolbar");
host.setAttribute("data-wrnexus-dev-toolbar", "");
@@ -11,7 +11,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
fetch("/__wrnexus/dev-toolbar.css").then(r => r.ok ? r.text() : "").then(css => style.textContent = css).catch(() => {});
root.appendChild(style);
const shell = document.createElement("div"); shell.className="wrn-shell"; shell.dataset.position=state.config.position;
shell.innerHTML='<section class="wrn-panel"><header class="wrn-panel-head"><div><div class="wrn-panel-title">WRNexus DevToolbar</div><div class="wrn-panel-meta"></div></div><button class="wrn-small" data-close>Close</button></header><div class="wrn-toolbar-row"><input class="wrn-search" placeholder="Search issues, rules, files…"><button class="wrn-small" data-scan>Rescan</button><button class="wrn-small" data-clear>Clear</button></div><div class="wrn-list"></div></section><nav class="wrn-bar"><span class="wrn-brand">WRNexus</span><button class="wrn-count" data-filter="error"><span class="wrn-dot error"></span><span data-errors>0</span></button><button class="wrn-count" data-filter="warning"><span class="wrn-dot warning"></span><span data-warnings>0</span></button><button class="wrn-count" data-filter="suggestion"><span class="wrn-dot suggestion"></span><span data-suggestions>0</span></button><button class="wrn-button" data-toggle>Inspect</button><button class="wrn-button" data-scan>↻</button></nav>';
shell.innerHTML='<section class="wrn-panel"><header class="wrn-panel-head"><div><div class="wrn-panel-title">WRNexus DevToolbar</div><div class="wrn-panel-meta"></div></div><button class="wrn-small" data-close>Close</button></header><div class="wrn-toolbar-row"><input class="wrn-search" placeholder="Search issues, rules, files…"><button class="wrn-small" data-category="all">Issues</button><button class="wrn-small" data-category="runtime">Runtime</button><button class="wrn-small" data-category="stores">Stores</button><button class="wrn-small" data-scan>Rescan</button><button class="wrn-small" data-clear>Clear</button></div><div class="wrn-list"></div></section><nav class="wrn-bar"><span class="wrn-brand">WRNexus</span><button class="wrn-count" data-filter="error"><span class="wrn-dot error"></span><span data-errors>0</span></button><button class="wrn-count" data-filter="warning"><span class="wrn-dot warning"></span><span data-warnings>0</span></button><button class="wrn-count" data-filter="suggestion"><span class="wrn-dot suggestion"></span><span data-suggestions>0</span></button><button class="wrn-button" data-toggle>Inspect</button><button class="wrn-button" data-scan>↻</button></nav>';
root.appendChild(shell);
const panel=root.querySelector(".wrn-panel"), list=root.querySelector(".wrn-list"), meta=root.querySelector(".wrn-panel-meta"), search=root.querySelector(".wrn-search");
const esc = value => String(value ?? "").replace(/[&<>\"']/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[char]);
@@ -39,18 +39,25 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));
const unique=new Map();[...state.issues.filter(x=>["runtime","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()];
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
const storeContainer=window.__wrnexusStoreContainer;
if(storeContainer&&typeof storeContainer.inspect==="function"){
for(const store of storeContainer.inspect())found.push(issue("stores/instance","stores","info",store.kind+" store: "+store.name,JSON.stringify({state:store.state,computed:store.computed}),null,"Use store actions for mutations. Sensitive server state is never hydrated.","high",store));
}
const unique=new Map();[...state.issues.filter(x=>["runtime","stores","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()];
state.report={url:location.href,pathname:location.pathname,title:document.title,status:200,generatedAt:Date.now(),issues:state.issues,metrics:{domNodes:count,cssResources:q('link[rel="stylesheet"]').length,scriptResources:q("script[src]").length,imageResources:q("img").length,totalTransferBytes:total,pageLoadMs:performance.getEntriesByType("navigation")[0]?.duration}}; render(); return state.report;
};
let highlightEl=null; const clearHighlight=()=>{highlightEl?.remove();highlightEl=null}; const highlight=sel=>{clearHighlight();let target;try{target=document.querySelector(sel)}catch{}if(!target)return;const r=target.getBoundingClientRect();highlightEl=document.createElement("div");highlightEl.className="wrn-highlight";Object.assign(highlightEl.style,{left:r.left+"px",top:r.top+"px",width:r.width+"px",height:r.height+"px"});root.appendChild(highlightEl);target.scrollIntoView({block:"center",behavior:"smooth"});setTimeout(clearHighlight,3000)};
const render=()=>{const filtered=state.issues.filter(x=>{const severityMatches=state.severity==="all"||x.severity===state.severity;const textMatches=!state.search||[x.title,x.message,x.ruleId,x.category,x.severity,x.source?.file].join(" ").toLowerCase().includes(state.search);return severityMatches&&textMatches;});root.querySelector("[data-errors]").textContent=state.issues.filter(x=>x.severity==="error").length;root.querySelector("[data-warnings]").textContent=state.issues.filter(x=>x.severity==="warning").length;root.querySelector("[data-suggestions]").textContent=state.issues.filter(x=>x.severity==="suggestion").length;root.querySelectorAll("[data-filter]").forEach(button=>{const active=button.dataset.filter===state.severity;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});meta.textContent=location.pathname+" · "+state.issues.length+" issues"+(state.severity!=="all"?" · "+state.severity:"");if(!filtered.length){list.innerHTML='<div class="wrn-empty">No issues match the current filters.</div>';return;}list.innerHTML=filtered.map(x=>'<article class="wrn-issue"><span class="wrn-severity '+esc(x.severity)+'"></span><div><div class="wrn-issue-title">'+esc(x.title)+'</div><div class="wrn-issue-message">'+esc(x.message)+'</div><div class="wrn-issue-meta">'+esc(x.severity)+' · '+esc(x.category)+' · '+esc(x.ruleId)+(x.source?.file?' · '+esc(x.source.file):'')+'</div></div><div class="wrn-actions">'+(x.target?.selector?'<button class="wrn-small" data-highlight="'+esc(x.target.selector)+'">Show</button>':'')+'</div></article>').join("");};
const render=()=>{const filtered=state.issues.filter(x=>{const severityMatches=state.severity==="all"||x.severity===state.severity;const categoryMatches=state.category==="all"||x.category===state.category;const textMatches=!state.search||[x.title,x.message,x.ruleId,x.category,x.severity,x.source?.file].join(" ").toLowerCase().includes(state.search);return severityMatches&&categoryMatches&&textMatches;});root.querySelector("[data-errors]").textContent=state.issues.filter(x=>x.severity==="error").length;root.querySelector("[data-warnings]").textContent=state.issues.filter(x=>x.severity==="warning").length;root.querySelector("[data-suggestions]").textContent=state.issues.filter(x=>x.severity==="suggestion").length;root.querySelectorAll("[data-filter]").forEach(button=>{const active=button.dataset.filter===state.severity;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});root.querySelectorAll("[data-category]").forEach(button=>{const active=button.dataset.category===state.category;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});meta.textContent=location.pathname+" · "+state.issues.length+" findings"+(state.category!=="all"?" · "+state.category:"")+(state.severity!=="all"?" · "+state.severity:"");if(!filtered.length){list.innerHTML='<div class="wrn-empty">No issues match the current filters.</div>';return;}list.innerHTML=filtered.map(x=>'<article class="wrn-issue"><span class="wrn-severity '+esc(x.severity)+'"></span><div><div class="wrn-issue-title">'+esc(x.title)+'</div><div class="wrn-issue-message">'+esc(x.message)+'</div><div class="wrn-issue-meta">'+esc(x.severity)+' · '+esc(x.category)+' · '+esc(x.ruleId)+(x.source?.file?' · '+esc(x.source.file):'')+'</div></div><div class="wrn-actions">'+(x.target?.selector?'<button class="wrn-small" data-highlight="'+esc(x.target.selector)+'">Show</button>':'')+'</div></article>').join("");};
const addRuntime=(severity,title,message,metadata={})=>{const x=issue("runtime/browser","runtime",severity,title,message,null,"Inspect the browser console and source stack.","high",metadata);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render()};
addEventListener("error",event=>{const target=event.target;if(target&&target!==window&&target.tagName){addRuntime("error","Resource failed to load",target.src||target.href||target.currentSrc||target.tagName,{tag:target.tagName});}else addRuntime("error",event.message||"Uncaught browser error",event.filename?event.filename+":"+event.lineno+":"+event.colno:"",{stack:event.error?.stack});},true);
addEventListener("unhandledrejection",event=>addRuntime("error","Unhandled promise rejection",event.reason?.message||String(event.reason),{stack:event.reason?.stack}));
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)});
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.category="all";state.search="";search.value="";render()}if(button.dataset.category){state.category=button.dataset.category;state.open=true;panel.classList.add("open");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();});
addEventListener("wrnexus:store-mutation",event=>{const mutation=event.detail||{};const x=issue("stores/mutation","stores","info","Store action: "+(mutation.store||"unknown")+"."+(mutation.action||"direct"),"Changed fields: "+((mutation.changed||[]).join(", ")||"none"),null,"Use the Stores panel to inspect safe client state.","high",mutation);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();});
fetch("/__wrnexus/dev-toolbar/platform").then(r=>r.ok?r.json():null).then(data=>{if(!data)return;state.platform=data.platform;state.panels=data.panels||[];render();}).catch(()=>{});
window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}};
const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]});
setTimeout(scan,100);
+1 -1
View File
@@ -1,4 +1,4 @@
export const DEV_TOOLBAR_CSS = String.raw`
:host{all:initial;color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#111318;--panel:#181b22;--line:#2b303b;--text:#f4f6fb;--muted:#9ca6b8;--error:#ff6b6b;--warning:#ffc857;--info:#72a7ff;--suggestion:#a78bfa}
*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(780px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.wrn-search{width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer}.wrn-small:hover{background:#303644}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(780px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.wrn-search{width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer}.wrn-small:hover{background:#303644}.wrn-small.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
`;
+1
View File
@@ -2,6 +2,7 @@ export type DevToolbarSeverity = "error" | "warning" | "info" | "suggestion";
export type DevToolbarCategory =
| "runtime"
| "stores"
| "compiler"
| "server"
| "routing"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+16
View File
@@ -55,6 +55,8 @@ export interface Router {
components: ComponentRef[];
/** Named page layouts (`app/layouts/<name>.wrn`); a page picks one via `layout`. */
layouts: ComponentRef[];
/** Typed global/page stores discovered under `app/stores/`. */
stores: ComponentRef[];
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
schemas: ComponentRef[];
matchPage(pathname: string): RouteMatch | null;
@@ -255,6 +257,19 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
layouts.push({ name, file: f.file });
}
const stores: ComponentRef[] = [];
for (const f of scanDir(join(appDir, "stores"))) {
if (!f.file.endsWith(".wrn")) continue;
try {
const source = readFileSync(f.file, "utf8");
const declaration = /\b(?:global|page)\s+store\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source);
if (!declaration) continue;
stores.push({ name: declaration[1]!, file: f.file });
} catch (error) {
console.warn(`[wrnexus] unable to inspect store declaration in ${f.file}`, error);
}
}
// Validation schemas: app/schemas/<name>.{ts,js}. Imported by API routes and
// referenced by forms via `data-schema="<name>"`.
const schemas: ComponentRef[] = [];
@@ -275,6 +290,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
middlewareFiles,
components,
layouts,
stores,
schemas,
matchPage: (p) => matchRoute(pages, p),
matchApi: (p) => matchRoute(api, p),
+6 -3
View File
@@ -1,12 +1,15 @@
{
"name": "@wrnexus/ssr",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./rpc": "./src/rpc.ts",
"./store-context": "./src/store-context.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*"
"@wrnexus/core": "workspace:*",
"@wrnexus/store": "workspace:*"
}
}
+3
View File
@@ -422,3 +422,6 @@ export function streamDocumentResponse(
if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
return new Response(renderDocumentStream(opts), { ...init, headers });
}
export * from "./rpc.ts";
export * from "./store-context.ts";
+227
View File
@@ -0,0 +1,227 @@
export interface RpcParameterContract {
name: string;
type: string;
optional: boolean;
}
export interface RpcManifestContract {
function: string;
parameters?: RpcParameterContract[];
returnType?: string;
}
export interface RpcRequestPayload {
component: string;
function: string;
args: unknown[];
}
export interface RpcContext {
request: Request;
user?: unknown;
traceId: string;
}
export interface RpcHandlerOptions {
resolve(component: string): Promise<{
functions: Record<string, (...args: any[]) => any>;
manifest?: RpcManifestContract[];
} | null>;
authenticate?: (request: Request) => Promise<unknown> | unknown;
authorize?: (context: RpcContext, payload: RpcRequestPayload) => Promise<boolean> | boolean;
validateCsrf?: (request: Request) => Promise<boolean> | boolean;
validateInput?: (
payload: RpcRequestPayload,
manifestEntry: RpcManifestContract,
) => Promise<unknown[]> | unknown[];
validateOutput?: (
value: unknown,
manifestEntry: RpcManifestContract,
) => Promise<unknown> | unknown;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
}
function error(
code: string,
message: string,
status: number,
traceId: string,
details?: unknown,
): Response {
return json(
{ ok: false, error: { code, message, traceId, ...(details === undefined ? {} : { details }) } },
status,
);
}
function validName(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z_$][\w$]*$/.test(value);
}
function removePromise(type: string): string {
const match = /^Promise\s*<([\s\S]+)>$/.exec(type.trim());
return match?.[1]?.trim() ?? type.trim();
}
function splitUnion(type: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let index = 0; index < type.length; index++) {
const char = type[index]!;
if (quote) {
if (char === "\\") index++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'") quote = char;
else if ("<([{".includes(char)) depth++;
else if (">)]}".includes(char)) depth = Math.max(0, depth - 1);
else if (char === "|" && depth === 0) {
parts.push(type.slice(start, index).trim());
start = index + 1;
}
}
parts.push(type.slice(start).trim());
return parts.filter(Boolean);
}
function matchesRuntimeType(value: unknown, rawType: string): boolean {
const type = removePromise(rawType).trim();
if (!type || ["unknown", "any", "never"].includes(type)) return true;
const union = splitUnion(type);
if (union.length > 1) return union.some((part) => matchesRuntimeType(value, part));
if (type === "undefined" || type === "void") return value === undefined;
if (type === "null") return value === null;
if (/^"[\s\S]*"$|^'[\s\S]*'$/.test(type)) return value === type.slice(1, -1);
if (/^-?\d+(?:\.\d+)?$/.test(type)) return value === Number(type);
if (type === "true") return value === true;
if (type === "false") return value === false;
if (type === "string") return typeof value === "string";
if (type === "number") return typeof value === "number" && Number.isFinite(value);
if (type === "boolean") return typeof value === "boolean";
if (type === "bigint") return typeof value === "bigint";
if (type === "Date")
return value instanceof Date || (typeof value === "string" && !Number.isNaN(Date.parse(value)));
if (type.endsWith("[]"))
return (
Array.isArray(value) && value.every((item) => matchesRuntimeType(item, type.slice(0, -2)))
);
if (/^(?:Readonly)?Array\s*</.test(type)) return Array.isArray(value);
if (/^(?:Record|Map|Set)\s*</.test(type)) return value !== null && typeof value === "object";
if (/^\{[\s\S]*\}$/.test(type))
return value !== null && typeof value === "object" && !Array.isArray(value);
// Named interfaces/types are validated by generated application validators
// when available. Their JSON transport shape must at least be object-like.
if (/^[A-Za-z_$][\w$]*(?:<.*>)?$/.test(type)) return value !== null && typeof value === "object";
return true;
}
function validateArguments(payload: RpcRequestPayload, manifest: RpcManifestContract): unknown[] {
const parameters = manifest.parameters ?? [];
const required = parameters.filter((parameter) => !parameter.optional).length;
if (payload.args.length < required || payload.args.length > parameters.length) {
throw new TypeError(
`WRN-RPC-INPUT: ${manifest.function} expects ${required === parameters.length ? required : `${required}-${parameters.length}`} arguments, received ${payload.args.length}.`,
);
}
parameters.forEach((parameter, index) => {
const value = payload.args[index];
if (value === undefined && parameter.optional) return;
if (!matchesRuntimeType(value, parameter.type)) {
throw new TypeError(
`WRN-RPC-INPUT: argument '${parameter.name}' expected ${parameter.type}.`,
);
}
});
return payload.args;
}
function validateReturn(value: unknown, manifest: RpcManifestContract): unknown {
const type = manifest.returnType ?? "unknown";
if (!matchesRuntimeType(value, type)) {
throw new TypeError(
`WRN-RPC-OUTPUT: ${manifest.function} returned a value incompatible with ${type}.`,
);
}
return value;
}
export function createRpcHandler(
options: RpcHandlerOptions,
): (request: Request) => Promise<Response> {
return async (request) => {
const traceId = request.headers.get("x-request-id") || crypto.randomUUID();
if (request.method !== "POST")
return error("WRN-RPC-METHOD", "RPC requires POST", 405, traceId);
if (options.validateCsrf && !(await options.validateCsrf(request))) {
return error("WRN-RPC-CSRF", "CSRF validation failed", 403, traceId);
}
let payload: RpcRequestPayload;
try {
payload = (await request.json()) as RpcRequestPayload;
} catch {
return error("WRN-RPC-JSON", "Invalid JSON request", 400, traceId);
}
if (
!validName(payload.component) ||
!validName(payload.function) ||
!Array.isArray(payload.args)
) {
return error("WRN-RPC-PAYLOAD", "Invalid RPC payload", 400, traceId);
}
const resolved = await options.resolve(payload.component);
if (!resolved)
return error(
"WRN-RPC-COMPONENT",
`Unknown RPC component '${payload.component}'`,
404,
traceId,
);
const manifestEntry = resolved.manifest?.find((entry) => entry.function === payload.function);
if (!manifestEntry) {
return error(
"WRN-RPC-FUNCTION",
`Server function '${payload.function}' is not remotely exposed`,
404,
traceId,
);
}
const fn = resolved.functions[payload.function];
if (typeof fn !== "function") {
return error(
"WRN-RPC-FUNCTION",
`Server function '${payload.function}' is not available`,
404,
traceId,
);
}
const user = options.authenticate ? await options.authenticate(request) : undefined;
const context: RpcContext = { request, user, traceId };
if (options.authorize && !(await options.authorize(context, payload))) {
return error("WRN-RPC-AUTHZ", "Not authorized", 403, traceId);
}
try {
const args = options.validateInput
? await options.validateInput(payload, manifestEntry)
: validateArguments(payload, manifestEntry);
const value = await fn(...args, context);
const validated = options.validateOutput
? await options.validateOutput(value, manifestEntry)
: validateReturn(value, manifestEntry);
return json({ ok: true, value: validated, traceId });
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
const code = message.startsWith("WRN-RPC-INPUT")
? "WRN-RPC-INPUT"
: message.startsWith("WRN-RPC-OUTPUT")
? "WRN-RPC-OUTPUT"
: "WRN-RPC-EXECUTION";
return error(code, message, code === "WRN-RPC-EXECUTION" ? 500 : 400, traceId);
}
};
}
+24
View File
@@ -0,0 +1,24 @@
import { createRequestStoreContainer } from "@wrnexus/store/server";
import type { StoreContainer } from "@wrnexus/store";
const containers = new WeakMap<object, StoreContainer>();
export function requestStoreContainer(request: object, routeId?: string): StoreContainer {
const existing = containers.get(request);
if (existing) return existing;
const created: StoreContainer = createRequestStoreContainer(request, routeId);
containers.set(request, created);
return created;
}
export async function disposeRequestStores(request: object): Promise<void> {
const container = containers.get(request);
if (!container) return;
containers.delete(request);
await container.dispose();
}
export function renderStoreHydration(container: StoreContainer, nonce?: string): string {
const json = JSON.stringify(container.serialize()).replace(/</g, "\\u003c");
return `<script type="application/json" data-wrnexus-store-hydration${nonce ? ` nonce="${nonce}"` : ""}>${json}</script>`;
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { createRpcHandler } from "../src/rpc.ts";
function request(args: unknown[]): Request {
return new Request("http://localhost/__wrnexus/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ component: "Demo", function: "save", args }),
});
}
const handler = createRpcHandler({
resolve: async () => ({
functions: { save: (value: string) => value.toUpperCase() },
manifest: [
{
function: "save",
parameters: [{ name: "value", type: "string", optional: false }],
returnType: "string",
},
],
}),
});
test("validates RPC input and output contracts", async () => {
const valid = await handler(request(["hello"]));
expect(valid.status).toBe(200);
expect(await valid.json()).toEqual(expect.objectContaining({ ok: true, value: "HELLO" }));
const invalid = await handler(request([12]));
expect(invalid.status).toBe(400);
expect(((await invalid.json()) as any).error.code).toBe("WRN-RPC-INPUT");
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@wrnexus/store",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts",
"./types": "./src/types.ts"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { createStoreContainer } from "./index.ts";
let globalContainer: ReturnType<typeof createStoreContainer> | undefined;
export function browserStoreContainer(hydration: Record<string, unknown> = {}) {
if (!globalContainer) {
globalContainer = createStoreContainer({
runtime: "client",
hydration,
onMutation(mutation) {
try {
globalThis.dispatchEvent?.(
new CustomEvent("wrnexus:store-mutation", { detail: mutation }),
);
} catch {
// Minimal DOM and non-browser runtimes may not expose CustomEvent.
}
},
});
(globalThis as any).__wrnexusStoreContainer = globalContainer;
}
return globalContainer;
}
export async function resetBrowserStores() {
await globalContainer?.dispose();
globalContainer = undefined;
delete (globalThis as any).__wrnexusStoreContainer;
}
+511
View File
@@ -0,0 +1,511 @@
import type {
StoreActionContext,
StoreDefinition,
StoreInstance,
StoreInstanceCore,
StoreMutation,
StorePersistenceConfig,
StoreCombinedState,
StoreFunction,
} from "./types.ts";
export type * from "./types.ts";
const memoryPersistence = new Map<string, string>();
function clone<T>(value: T): T {
if (typeof structuredClone === "function") {
try {
return structuredClone(value);
} catch {
/* Proxies are cloned through their JSON-visible state below. */
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
function readonlySnapshot<S extends object>(state: S): Readonly<S> {
return Object.freeze(clone(state));
}
function storageFor(kind: StorePersistenceConfig<any>["storage"]): Storage | null {
if (typeof window === "undefined") return null;
if (kind === "local") return window.localStorage;
if (kind === "session") return window.sessionStorage;
return null;
}
function readPersisted<S extends object>(
key: string,
config: StorePersistenceConfig<S>,
): Partial<S> | null {
try {
const raw =
config.storage === "memory"
? memoryPersistence.get(key)
: storageFor(config.storage)?.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as { version?: number; state?: unknown };
const from = Number(parsed.version ?? 0);
let state = parsed.state;
if (from !== config.version && config.migrate)
state = config.migrate(state, from, config.version);
if (config.validate) return config.validate(state);
if (!state || typeof state !== "object" || Array.isArray(state)) return null;
return state as Partial<S>;
} catch {
return null;
}
}
function writePersisted<S extends object>(
key: string,
state: S,
config: StorePersistenceConfig<S>,
): void {
const picked: Record<string, unknown> = {};
for (const name of config.include) picked[name] = state[name];
const raw = JSON.stringify({ version: config.version, state: picked });
if (config.storage === "memory") memoryPersistence.set(key, raw);
else storageFor(config.storage)?.setItem(key, raw);
}
export function defineStore<
S extends object,
C extends object = Record<string, never>,
A extends Record<string, StoreFunction> = Record<string, StoreFunction>,
CS extends object = Record<string, never>,
SS extends object = Record<string, never>,
>(definition: StoreDefinition<S, C, A, CS, SS>): StoreDefinition<S, C, A, CS, SS> {
return definition;
}
export interface StoreContainerOptions {
runtime: "server" | "client";
request?: unknown;
routeId?: string;
hydration?: Record<string, unknown>;
onMutation?: (mutation: StoreMutation) => void;
}
export class StoreContainer {
readonly runtime: "server" | "client";
readonly request?: unknown;
readonly routeId?: string;
private readonly instances = new Map<string, StoreInstance<any, any, any>>();
private readonly hydration: Record<string, unknown>;
private readonly onMutation?: (mutation: StoreMutation) => void;
private readonly lastMutations = new Map<string, StoreMutation>();
constructor(options: StoreContainerOptions) {
this.runtime = options.runtime;
this.request = options.request;
this.routeId = options.routeId;
this.hydration = options.hydration ?? {};
this.onMutation = options.onMutation;
}
async use<
S extends object,
C extends object,
A extends Record<string, StoreFunction>,
CS extends object,
SS extends object,
>(
definition: StoreDefinition<S, C, A, CS, SS>,
): Promise<StoreInstance<StoreCombinedState<S, CS, SS>, C, A>> {
const key =
definition.kind === "page"
? `${definition.name}@${this.routeId ?? "default"}`
: definition.name;
const existing = this.instances.get(key);
if (existing) return existing as StoreInstance<StoreCombinedState<S, CS, SS>, C, A>;
const instance = createStoreInstance(definition, {
runtime: this.runtime,
request: this.request,
routeId: this.routeId,
hydration: this.hydration[definition.name],
onMutation: (mutation) => {
this.lastMutations.set(definition.name, mutation);
this.onMutation?.(mutation);
},
});
this.instances.set(key, instance);
await instance.whenReady;
return instance;
}
serialize(): Record<string, unknown> {
return Object.fromEntries(
Array.from(this.instances.values(), (instance) => [instance.name, instance.serialize()]),
);
}
inspect(): Array<{
name: string;
kind: "global" | "page";
state: Readonly<Record<string, unknown>>;
computed: Readonly<Record<string, unknown>>;
lastAction?: string;
changed: string[];
hydrationSource: "server" | "persistence" | "initial";
}> {
return Array.from(this.instances.values(), (instance) => {
const mutation = this.lastMutations.get(instance.name);
return {
name: instance.name,
kind: instance.kind,
state: instance.snapshot() as Readonly<Record<string, unknown>>,
computed: Object.freeze(
Object.fromEntries(
Reflect.ownKeys(instance.computed).map((key) => [
String(key),
(instance.computed as any)[key],
]),
),
),
...(mutation ? { lastAction: mutation.action } : {}),
changed: mutation?.changed ?? [],
hydrationSource: this.hydration[instance.name] ? "server" : "initial",
};
});
}
async hotUpdate<
S extends object,
C extends object,
A extends Record<string, StoreFunction>,
CS extends object,
SS extends object,
>(
definition: StoreDefinition<S, C, A, CS, SS>,
): Promise<{ preserved: string[]; reset: string[] }> {
const entries = [...this.instances.entries()].filter(
([, instance]) => instance.name === definition.name,
);
const preserved = new Set<string>();
const reset = new Set<string>();
for (const [key, previous] of entries) {
const snapshot = previous.snapshot() as Record<string, unknown>;
await previous.dispose();
const freshShared = definition.createSharedState();
const freshRuntime =
this.runtime === "client"
? (definition.createClientState?.() ?? {})
: (definition.createServerState?.() ?? {});
const nextShape = { ...freshShared, ...freshRuntime } as Record<string, unknown>;
const compatible: Record<string, unknown> = {};
for (const [name, value] of Object.entries(snapshot)) {
if (!(name in nextShape)) continue;
const expected = nextShape[name];
const same =
expected === null || value === null
? expected === value || expected === null
: Array.isArray(expected)
? Array.isArray(value)
: typeof expected === typeof value;
if (same) {
compatible[name] = value;
preserved.add(name);
} else reset.add(name);
}
for (const name of Object.keys(nextShape))
if (!(name in compatible) && name in snapshot) reset.add(name);
const instance = createStoreInstance(definition, {
runtime: this.runtime,
request: this.request,
routeId: this.routeId,
hydration: compatible,
onMutation: (mutation) => {
this.lastMutations.set(definition.name, mutation);
this.onMutation?.(mutation);
},
});
this.instances.set(key, instance);
await instance.whenReady;
}
return { preserved: [...preserved], reset: [...reset] };
}
async disposePageStores(): Promise<void> {
for (const [key, instance] of [...this.instances]) {
if (instance.kind !== "page") continue;
await instance.dispose();
this.instances.delete(key);
}
}
async dispose(): Promise<void> {
for (const instance of this.instances.values()) await instance.dispose();
this.instances.clear();
}
}
export function createStoreContainer(options: StoreContainerOptions): StoreContainer {
return new StoreContainer(options);
}
export function createStoreInstance<
S extends object,
C extends object,
A extends Record<string, StoreFunction>,
CS extends object,
SS extends object,
>(
definition: StoreDefinition<S, C, A, CS, SS>,
options: Omit<StoreContainerOptions, "hydration"> & { hydration?: unknown },
): StoreInstance<StoreCombinedState<S, CS, SS>, C, A> {
type State = StoreCombinedState<S, CS, SS>;
const createInitialState = (): State =>
({
...definition.createSharedState(),
...(options.runtime === "client"
? definition.createClientState?.()
: definition.createServerState?.()),
}) as State;
const initial = createInitialState();
const persisted =
options.runtime === "client" && definition.persist
? readPersisted(`wrnexus:store:${definition.name}`, definition.persist)
: null;
const hydrated =
options.hydration && typeof options.hydration === "object"
? (options.hydration as Partial<State>)
: null;
const raw = Object.assign(initial, persisted ?? {}, hydrated ?? {});
const listeners = new Set<(snapshot: Readonly<State>, mutation?: StoreMutation) => void>();
let currentAction = "direct";
let internalMutation = false;
const mutableState = new Proxy(raw, {
set(target, property, value) {
if (!internalMutation) {
throw new TypeError(
`WRN-STORE-READONLY: ${definition.name}.${String(property)} must be changed by a store action.`,
);
}
if (Object.is(Reflect.get(target, property), value)) return true;
const before = readonlySnapshot(target);
Reflect.set(target, property, value);
const after = readonlySnapshot(target);
const mutation: StoreMutation = {
store: definition.name,
action: currentAction,
changed: [String(property)],
before: before as Readonly<Record<string, unknown>>,
after: after as Readonly<Record<string, unknown>>,
timestamp: Date.now(),
};
if (definition.persist && options.runtime === "client") {
writePersisted(`wrnexus:store:${definition.name}`, target, definition.persist);
}
options.onMutation?.(mutation);
for (const listener of listeners) listener(after, mutation);
return true;
},
deleteProperty(target, property) {
if (!internalMutation) {
throw new TypeError(
`WRN-STORE-READONLY: ${definition.name}.${String(property)} must be changed by a store action.`,
);
}
return Reflect.deleteProperty(target, property);
},
});
const publicState = new Proxy({} as State, {
get(_target, property) {
return (mutableState as any)[property];
},
set(_target, property) {
throw new TypeError(
`WRN-STORE-READONLY: ${definition.name}.${String(property)} must be changed by a store action.`,
);
},
deleteProperty(_target, property) {
throw new TypeError(
`WRN-STORE-READONLY: ${definition.name}.${String(property)} must be changed by a store action.`,
);
},
ownKeys() {
return Reflect.ownKeys(mutableState);
},
getOwnPropertyDescriptor(_target, property) {
if (!(property in mutableState)) return undefined;
return {
enumerable: true,
configurable: true,
value: (mutableState as any)[property],
writable: false,
};
},
has(_target, property) {
return property in mutableState;
},
});
const mutate = <T>(action: string, callback: () => T): T => {
const previousAction = currentAction;
const previousMutation = internalMutation;
currentAction = action;
internalMutation = true;
try {
return callback();
} finally {
currentAction = previousAction;
internalMutation = previousMutation;
}
};
const reset = () =>
mutate("$reset", () => {
const next = createInitialState();
for (const key of Object.keys(mutableState)) {
if (!(key in next)) delete (mutableState as any)[key];
}
Object.assign(mutableState, next);
});
const context: StoreActionContext<State> = {
state: mutableState,
snapshot: () => readonlySnapshot(mutableState),
reset,
runtime: options.runtime,
request: options.request,
routeId: options.routeId,
};
const actions = {} as A;
for (const [name, rawDefinitions] of Object.entries(definition.actions ?? {})) {
const definitions = Array.isArray(rawDefinitions) ? rawDefinitions : [rawDefinitions];
const selected =
definitions.find((entry) => entry.runtime === options.runtime) ??
definitions.find((entry) => entry.runtime === "shared") ??
definitions.find((entry) => entry.runtime === "legacy");
if (!selected) continue;
(actions as any)[name] = async (...args: unknown[]) => {
currentAction = name;
internalMutation = true;
try {
return await (selected.handler as any)(context, ...args);
} finally {
currentAction = "direct";
internalMutation = false;
}
};
}
const computed = new Proxy({} as C, {
get(_target, property) {
const fn = definition.computed?.[property as keyof C];
return fn ? fn(publicState) : undefined;
},
set() {
throw new TypeError("Computed store values are readonly");
},
ownKeys() {
return Reflect.ownKeys(definition.computed ?? {});
},
getOwnPropertyDescriptor() {
return { enumerable: true, configurable: true };
},
});
const lifecycleContext = {
state: mutableState,
runtime: options.runtime,
request: options.request,
routeId: options.routeId,
};
const runLifecycle = async (
name: string,
hook?: (context: typeof lifecycleContext) => void | Promise<void>,
): Promise<void> => {
if (!hook) return;
const previousAction = currentAction;
const previousMutation = internalMutation;
currentAction = name;
internalMutation = true;
try {
await hook(lifecycleContext);
} finally {
currentAction = previousAction;
internalMutation = previousMutation;
}
};
const initHook =
options.runtime === "server"
? definition.lifecycle?.serverInit
: definition.lifecycle?.clientInit;
const ready = runLifecycle(
options.runtime === "server" ? "$serverInit" : "$clientInit",
initHook,
).then(async () => {
if (options.runtime === "client" && options.hydration && definition.lifecycle?.hydrate) {
await runLifecycle("$hydrate", definition.lifecycle.hydrate);
}
});
const core: StoreInstanceCore<State, C, A> = {
name: definition.name,
kind: definition.kind,
state: publicState,
computed,
actions,
whenReady: ready,
reset,
snapshot: () => readonlySnapshot(mutableState),
hydrate(value) {
mutate("$hydrate", () => Object.assign(mutableState, value));
},
serialize() {
const result: Partial<State> = {};
const serverKeys = new Set(Object.keys(definition.createServerState?.() ?? {}));
for (const [key, value] of Object.entries(mutableState)) {
if (!serverKeys.has(key)) (result as any)[key] = clone(value);
}
return result;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async dispose() {
await ready;
await runLifecycle("$dispose", definition.lifecycle?.dispose);
listeners.clear();
},
};
return new Proxy(core as StoreInstance<State, C, A>, {
get(target, property, receiver) {
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver);
if (property in actions) return (actions as any)[property];
if (property in (definition.computed ?? {})) return (computed as any)[property];
if (property in mutableState) return (publicState as any)[property];
return undefined;
},
set(_target, property) {
throw new TypeError(
`WRN-STORE-READONLY: ${definition.name}.${String(property)} must be changed by a store action.`,
);
},
ownKeys(target) {
return [
...new Set([
...Reflect.ownKeys(target),
...Reflect.ownKeys(mutableState),
...Reflect.ownKeys(definition.computed ?? {}),
...Reflect.ownKeys(actions),
]),
];
},
getOwnPropertyDescriptor(target, property) {
return (
Reflect.getOwnPropertyDescriptor(target, property) ?? {
enumerable: true,
configurable: true,
}
);
},
});
}
+9
View File
@@ -0,0 +1,9 @@
import { createStoreContainer, type StoreContainerOptions } from "./index.ts";
export function createRequestStoreContainer(
request: unknown,
routeId?: string,
options: Omit<StoreContainerOptions, "runtime" | "request" | "routeId"> = {},
) {
return createStoreContainer({ ...options, runtime: "server", request, routeId });
}
+111
View File
@@ -0,0 +1,111 @@
export type StoreKind = "global" | "page";
export type StoreRuntime = "shared" | "client" | "server" | "legacy";
export type PersistenceStorage = "memory" | "session" | "local";
export type StoreFunction = (...args: any[]) => any;
export type StoreCombinedState<
S extends object,
CS extends object = Record<string, never>,
SS extends object = Record<string, never>,
> = S & Partial<CS> & Partial<SS>;
export interface StorePersistenceConfig<S extends object> {
storage: PersistenceStorage;
include: Array<keyof S & string>;
version: number;
migrate?: (value: unknown, fromVersion: number, toVersion: number) => Partial<S>;
validate?: (value: unknown) => Partial<S> | null;
}
export interface StoreLifecycleContext<S extends object> {
state: S;
runtime: "server" | "client";
request?: unknown;
routeId?: string;
}
export interface StoreActionDefinition<S extends object, F extends StoreFunction = StoreFunction> {
runtime: StoreRuntime;
handler: (context: StoreActionContext<S>, ...args: Parameters<F>) => ReturnType<F>;
}
export interface StoreDefinition<
S extends object,
C extends object = Record<string, never>,
A extends Record<string, StoreFunction> = Record<string, StoreFunction>,
CS extends object = Record<string, never>,
SS extends object = Record<string, never>,
> {
name: string;
kind: StoreKind;
createSharedState: () => S;
createClientState?: () => CS;
createServerState?: () => SS;
computed?: {
[K in keyof C]: (state: Readonly<StoreCombinedState<S, CS, SS>>) => C[K];
};
actions?: {
[K in keyof A]:
| StoreActionDefinition<StoreCombinedState<S, CS, SS>, A[K]>
| Array<StoreActionDefinition<StoreCombinedState<S, CS, SS>, A[K]>>;
};
persist?: StorePersistenceConfig<StoreCombinedState<S, CS, SS>>;
lifecycle?: {
serverInit?: (
context: StoreLifecycleContext<StoreCombinedState<S, CS, SS>>,
) => void | Promise<void>;
clientInit?: (
context: StoreLifecycleContext<StoreCombinedState<S, CS, SS>>,
) => void | Promise<void>;
hydrate?: (
context: StoreLifecycleContext<StoreCombinedState<S, CS, SS>>,
) => void | Promise<void>;
dispose?: (
context: StoreLifecycleContext<StoreCombinedState<S, CS, SS>>,
) => void | Promise<void>;
};
}
export interface StoreActionContext<S extends object> {
state: S;
snapshot(): Readonly<S>;
reset(): void;
runtime: "server" | "client";
request?: unknown;
routeId?: string;
}
export interface StoreMutation {
store: string;
action: string;
changed: string[];
before: Readonly<Record<string, unknown>>;
after: Readonly<Record<string, unknown>>;
timestamp: number;
}
export interface StoreInstanceCore<
S extends object,
C extends object,
A extends Record<string, StoreFunction>,
> {
readonly name: string;
readonly kind: StoreKind;
readonly state: Readonly<S>;
readonly computed: Readonly<C>;
readonly actions: A;
/** Internal initialization promise. `whenReady` avoids colliding with store state named `ready`. */
readonly whenReady: Promise<void>;
reset(): void;
snapshot(): Readonly<S>;
hydrate(value: Partial<S>): void;
serialize(): Partial<S>;
subscribe(listener: (snapshot: Readonly<S>, mutation?: StoreMutation) => void): () => void;
dispose(): Promise<void>;
}
export type StoreInstance<
S extends object,
C extends object,
A extends Record<string, StoreFunction>,
> = StoreInstanceCore<S, C, A> & Readonly<S> & Readonly<C> & A;
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test";
import { createStoreContainer, defineStore } from "../src/index.ts";
const CounterStore = defineStore({
name: "CounterStore",
kind: "global" as const,
createSharedState: () => ({ count: 0 }),
createClientState: () => ({ viewport: 0 }),
createServerState: () => ({ secret: "server-only" }),
computed: { doubled: (state: Readonly<{ count: number }>) => state.count * 2 },
actions: {
increment: [
{
runtime: "client" as const,
handler: ({ state }: any, amount = 1) => {
state.count += amount;
},
},
{
runtime: "server" as const,
handler: ({ state }: any, amount = 1) => {
state.count += amount * 2;
},
},
],
},
});
test("isolates request-scoped server stores and excludes server state", async () => {
const first = createStoreContainer({ runtime: "server", request: {} });
const second = createStoreContainer({ runtime: "server", request: {} });
const a = await first.use(CounterStore);
const b = await second.use(CounterStore);
await a.increment(2);
expect(a.count).toBe(4);
expect(a.doubled).toBe(8);
expect(b.count).toBe(0);
expect(first.serialize()).toEqual({ CounterStore: { count: 4 } });
});
test("store state is readonly outside actions and supports snapshots/reset", async () => {
const container = createStoreContainer({ runtime: "client" });
const store = await container.use(CounterStore);
expect(() => {
(store.state as any).count = 5;
}).toThrow("WRN-STORE-READONLY");
await store.increment(3);
expect(store.snapshot().count).toBe(3);
store.reset();
expect(store.count).toBe(0);
});
test("HMR preserves compatible state and resets incompatible fields", async () => {
const container = createStoreContainer({ runtime: "client" });
const store = await container.use(CounterStore);
await store.increment(2);
const result = await container.hotUpdate(
defineStore({
...CounterStore,
createSharedState: () => ({ count: 0, added: true }),
} as any),
);
expect(result.preserved).toContain("count");
});
test("store lifecycle hooks may update state and page stores dispose cleanly", async () => {
const calls: string[] = [];
const LifecycleStore = defineStore({
name: "LifecycleStore",
kind: "page" as const,
createSharedState: () => ({ ready: false }),
lifecycle: {
clientInit: async ({ state }: any) => {
calls.push("clientInit");
state.ready = true;
},
dispose: async ({ state }: any) => {
calls.push("dispose");
state.ready = false;
},
},
});
const container = createStoreContainer({ runtime: "client", routeId: "/one" });
const store = await container.use(LifecycleStore);
expect(store.ready).toBe(true);
await container.disposePageStores();
expect(calls).toEqual(["clientInit", "dispose"]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+42
View File
@@ -191,9 +191,51 @@ export interface NavigationConfig {
mode?: "client" | "document";
}
export interface ImportsConfig {
mode?: "legacy" | "compatible" | "explicit";
autoImport?: boolean;
aliases?: Record<string, string>;
}
export interface TypesConfig {
strict?: boolean;
noImplicitAny?: boolean;
strictNullChecks?: boolean;
checkTemplates?: boolean;
checkComponentProps?: boolean;
generateDeclarations?: boolean;
globalTypes?: string;
}
export interface FunctionsConfig {
legacyDefaultRuntime?: "current" | "client" | "server" | "shared";
}
export interface StoresConfig {
strictMutations?: boolean;
persistence?: boolean;
}
export interface CompatibilityConfig {
legacyEmit?: boolean;
legacyEventProps?: boolean;
legacyComponentDiscovery?: boolean;
stringLayouts?: boolean;
}
export interface AppConfig {
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
plugins?: PluginInput;
/** WRN v0.6 explicit import and compatibility resolution. */
imports?: ImportsConfig;
/** TypeScript-backed .wrn type checking and declaration generation. */
types?: TypesConfig;
/** Legacy function runtime behavior for existing applications. */
functions?: FunctionsConfig;
/** Typed global/page store behavior. */
stores?: StoresConfig;
/** Temporary v0.5 syntax compatibility switches. */
compatibility?: CompatibilityConfig;
/** Opt-in APIs that are not yet covered by stable compatibility guarantees. */
experimental?: ExperimentalConfig;
/** Route and asset budgets plus build analyzer behavior. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.5.14",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+186
View File
@@ -22,6 +22,9 @@ export interface WrnDiagnostic {
hint?: string;
file?: string;
position?: WrnSourcePosition;
expected?: string;
received?: string;
related?: Array<{ file?: string; message: string; position?: WrnSourcePosition }>;
}
export interface DiagnoseOptions {
@@ -29,6 +32,95 @@ export interface DiagnoseOptions {
accessibility?: boolean;
}
function maskJavaScriptTrivia(source: string): string {
let result = "";
let index = 0;
let quote: "'" | '"' | "`" | null = null;
let lineComment = false;
let blockComment = false;
while (index < source.length) {
const char = source[index]!;
const next = source[index + 1];
if (lineComment) {
if (char === "\n") {
lineComment = false;
result += "\n";
} else result += " ";
index++;
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
result += " ";
index += 2;
blockComment = false;
} else {
result += char === "\n" ? "\n" : " ";
index++;
}
continue;
}
if (quote) {
if (char === "\\") {
result += " ";
index += Math.min(2, source.length - index);
} else if (char === quote) {
result += " ";
index++;
quote = null;
} else {
result += char === "\n" ? "\n" : " ";
index++;
}
continue;
}
if (char === "/" && next === "/") {
result += " ";
index += 2;
lineComment = true;
continue;
}
if (char === "/" && next === "*") {
result += " ";
index += 2;
blockComment = true;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quote = char;
result += " ";
index++;
continue;
}
result += char;
index++;
}
return result;
}
export function containsReadonlyPropMutation(
body: string,
propName: string,
parameterNames: Set<string>,
): boolean {
const code = maskJavaScriptTrivia(body);
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const operator = String.raw`(?:\+\+|--|(?:\*\*|&&|\|\||\?\?|[+\-*/%&|^])?=(?!=|>))`;
if (new RegExp(String.raw`\bprops\.${escaped}\s*${operator}`).test(code)) return true;
if (parameterNames.has(propName)) return false;
if (new RegExp(String.raw`\b(?:const|let|var)\s+${escaped}\b`).test(code)) return false;
return new RegExp(String.raw`(?:^|[^\w$.])${escaped}\s*${operator}`, "m").test(code);
}
export function positionAt(source: string, offset: number): WrnSourcePosition {
const safe = Math.max(0, Math.min(offset, source.length));
const before = source.slice(0, safe);
@@ -155,6 +247,100 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
file: options.file,
});
}
const outputs = new Set(ast.outputs.map((output) => output.name));
for (const fn of ast.runtimeFunctions) {
for (const call of fn.body.matchAll(/\boutput\.([A-Za-z_$][\w$]*)\s*\(/g)) {
const outputName = call[1]!;
if (fn.runtime === "server") {
diagnostics.push({
code: "WRN-OUTPUT-SERVER-CALL",
severity: "error",
message: `Server function '${fn.name}' cannot call output.${outputName}().`,
hint: "Return a typed value to the browser and call the output from a client function.",
file: options.file,
});
} else if (!outputs.has(outputName)) {
diagnostics.push({
code: "WRN-OUTPUT-UNKNOWN",
severity: "error",
message: `Unknown output '${outputName}' called from '${fn.name}'.`,
hint: `Declare ${outputName}(payload) inside outputs { ... }.`,
file: options.file,
});
}
}
if (fn.runtime === "client" && /\b(?:process|Bun|Deno|__dirname|require)\b/.test(fn.body)) {
diagnostics.push({
code: "WRN-CLIENT-SERVER-API",
severity: "error",
message: `Client function '${fn.name}' references a server-only API.`,
hint: "Move that operation into a server function and call it through server.name(...).",
file: options.file,
});
}
if (
fn.runtime === "server" &&
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
) {
diagnostics.push({
code: "WRN-SERVER-BROWSER-API",
severity: "error",
message: `Server function '${fn.name}' references a browser-only API.`,
hint: "Move that code into a client function.",
file: options.file,
});
}
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
for (const prop of ast.props) {
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
diagnostics.push({
code: "WRN-PROP-READONLY",
severity: "error",
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
hint: "Copy the prop into state before mutating it.",
file: options.file,
});
}
}
}
for (const state of ast.states) {
if (
state.runtime === "shared" &&
/^(?:new\s+(?:Map|Set|WeakMap|WeakSet)|(?:async\s+)?function\b|.*=>)/.test(state.expr.trim())
) {
diagnostics.push({
code: "WRN-STATE-NON-SERIALIZABLE",
severity: "error",
message: `Shared state '${state.name}' is not safely serializable.`,
hint: "Use JSON-compatible data or move the value into client/server state.",
file: options.file,
});
}
}
if (ast.persist) {
const stateNames = new Set(
ast.states.filter((state) => state.runtime !== "server").map((state) => state.name),
);
for (const name of ast.persist.include)
if (!stateNames.has(name))
diagnostics.push({
code: "WRN-PERSIST-UNKNOWN-FIELD",
severity: "error",
message: `Persist include references unknown or server-only state '${name}'.`,
hint: "Persist only declared shared/client state fields.",
file: options.file,
});
for (const name of ast.persist.include)
if (/token|password|secret|otp|api.?key/i.test(name))
diagnostics.push({
code: "WRN-PERSIST-SENSITIVE",
severity: "error",
message: `Sensitive field '${name}' cannot be persisted.`,
hint: "Remove secrets, tokens, passwords, OTPs, and API keys from persistence.",
file: options.file,
});
}
return diagnostics;
}
+23
View File
@@ -31,6 +31,7 @@ export type { RuntimeType } from "./types.ts";
export {
assertValidAst,
classifyParseError,
containsReadonlyPropMutation,
diagnose,
diagnosticFromError,
formatDiagnostic,
@@ -54,3 +55,25 @@ export {
supportsSyntaxFeature,
} from "./versioning.ts";
export type { SourceRange, WrnSyntaxFeature } from "./versioning.ts";
export {
parseComputedDeclarations,
parseOutputs,
parsePersist,
parseRuntimeFunctions,
parseStateDeclarations,
parseStoreLifecycle,
parseStructuredImports,
stripRuntimeFunctionModifiers,
} from "./v060.ts";
export type {
FunctionParameterDecl,
FunctionRuntime,
OutputDecl,
PersistDecl,
RuntimeFunctionDecl,
StateRuntime,
StoreKind,
StoreLifecycleDecl,
StructuredImportDecl,
} from "./v060.ts";
+219 -55
View File
@@ -24,9 +24,27 @@
import { Lexer, LexError, type Token } from "./tokenizer.ts";
import { validateTypedInitializer } from "./types.ts";
import {
parseComputedDeclarations,
parseOutputs,
parsePersist,
parseRuntimeFunctions,
parseStateDeclarations,
parseStoreLifecycle,
parseStructuredImports,
type OutputDecl,
type PersistDecl,
type RuntimeFunctionDecl,
type StateRuntime,
type StoreKind,
type StoreLifecycleDecl,
type StructuredImportDecl,
} from "./v060.ts";
export interface StateDecl {
name: string;
/** Runtime visibility. Legacy declarations are shared. */
runtime: StateRuntime;
/** Explicit TypeScript-style type annotation, when supplied. */
valueType?: string;
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
@@ -35,6 +53,7 @@ export interface StateDecl {
export interface ComputedDecl {
name: string;
valueType?: string;
expr: string;
}
@@ -166,7 +185,7 @@ export interface PropDecl {
}
export interface EventDecl {
/** Public event name used by consumers as `@name="handler(event)"`. */
/** Legacy public event declaration retained for compatibility. */
name: string;
}
@@ -174,22 +193,27 @@ export interface PageAst {
type: "page";
/** Static ES module imports declared before the WRN root declaration. */
imports: string[];
structuredImports: StructuredImportDecl[];
/**
* `page` is a route, `component` is a reusable fragment,
* and `layout` is a reusable page wrapper.
*/
kind: "page" | "component" | "layout";
kind: "page" | "component" | "layout" | "global-store" | "page-store";
storeKind?: StoreKind;
name: string;
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
layout?: string;
layoutIsSymbol?: boolean;
/** 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[];
/** Public events exposed by a reusable component. */
/** Legacy public events exposed by a reusable component. */
events: EventDecl[];
/** Canonical typed callable outputs. */
outputs: OutputDecl[];
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
types: string[];
states: StateDecl[];
@@ -202,9 +226,12 @@ export interface PageAst {
view: ViewNode[];
styles: string[];
functions: string[];
runtimeFunctions: RuntimeFunctionDecl[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
lifecycle: LifecycleBlock;
storeLifecycle: StoreLifecycleDecl;
persist?: PersistDecl;
watches: WatchBlock[];
apis: ApiBlock[];
realtimes: RealtimeBlock[];
@@ -277,26 +304,49 @@ export function parse(source: string): PageAst {
};
try {
// A file may contain a page, reusable component, or reusable layout.
// A file may contain a page, component, layout, global store, or page store.
const opener = lx.next();
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
if (
opener.type !== "ident" ||
!["page", "component", "layout", "global"].includes(opener.value)
) {
throw new ParseError(
`Expected 'page', 'component', or 'layout' but got '${
`Expected 'page', 'component', 'layout', 'global store', or 'page store' but got '${
opener.value || opener.type
}' at offset ${opener.pos}`,
);
}
const kind = opener.value as "page" | "component" | "layout";
const name = expect("ident").value;
let kind: PageAst["kind"];
let storeKind: StoreKind | undefined;
let name: string;
if (opener.value === "global") {
expectKeyword("store");
kind = "global-store";
storeKind = "global";
name = expect("ident").value;
} else if (
opener.value === "page" &&
lx.peek().type === "ident" &&
lx.peek().value === "store"
) {
lx.next();
kind = "page-store";
storeKind = "page";
name = expect("ident").value;
} else {
kind = opener.value as "page" | "component" | "layout";
name = expect("ident").value;
}
expect("lbrace");
let layout: string | undefined;
let layoutIsSymbol = false;
let runtime: PageAst["runtime"];
let hydrate: string | undefined;
const props: PropDecl[] = [];
const events: EventDecl[] = [];
const outputs: OutputDecl[] = [];
const types: string[] = [];
const states: StateDecl[] = [];
const computed: ComputedDecl[] = [];
@@ -308,9 +358,12 @@ export function parse(source: string): PageAst {
const view: ViewNode[] = [];
const styles: string[] = [];
const functions: string[] = [];
const runtimeFunctions: RuntimeFunctionDecl[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const lifecycle: LifecycleBlock = {};
let storeLifecycle: StoreLifecycleDecl = {};
let persist: PersistDecl | undefined;
const watches: WatchBlock[] = [];
const apis: ApiBlock[] = [];
const realtimes: RealtimeBlock[] = [];
@@ -326,7 +379,14 @@ export function parse(source: string): PageAst {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
expect("eq");
layout = expect("string").value;
const layoutToken = lx.next();
if (layoutToken.type !== "string" && layoutToken.type !== "ident") {
throw new ParseError(
`Expected a layout string or imported symbol at offset ${layoutToken.pos}`,
);
}
layout = layoutToken.value;
layoutIsSymbol = layoutToken.type === "ident";
break;
}
case "runtime": {
@@ -378,6 +438,7 @@ export function parse(source: string): PageAst {
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
}
const pName = expect("ident").value;
const optional = lx.peek().type === "question" ? (lx.next(), true) : false;
let valueType: string | undefined;
let hasDefault = false;
if (lx.peek().type === "colon") {
@@ -390,43 +451,70 @@ export function parse(source: string): PageAst {
hasDefault = true;
}
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
props.push({
name: pName,
valueType,
required: !hasDefault && !optional,
default: defaultValue,
});
}
expect("rbrace");
break;
}
case "state": {
lx.next();
if (lx.peek().type === "lbrace") {
const grouped = parseStateDeclarations(lx.readBalancedBraces(), "shared");
states.push(...grouped);
break;
}
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) {
if (!annotation.hasDefault)
throw new ParseError(`State '${sName}' requires an initializer`);
}
} else {
expect("eq");
}
// State values may be multiline structured expressions. Use the same
// balanced initializer reader as props so formatted arrays/objects
// remain one declaration instead of exposing their inner braces as
// component members on the next line.
states.push({ name: sName, valueType, expr: lx.readPropInitializer() });
states.push({
name: sName,
valueType,
expr: lx.readPropInitializer(),
runtime: "shared",
});
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() });
if (lx.peek().type === "lbrace") {
computed.push(...parseComputedDeclarations(lx.readBalancedBraces()));
} else {
const cName = 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(`Computed '${cName}' requires an expression`);
} else expect("eq");
computed.push({ name: cName, valueType, expr: lx.readPropInitializer() });
}
break;
}
case "outputs": {
lx.next();
try {
outputs.push(...parseOutputs(lx.readBalancedBraces()));
} catch (error) {
throw new ParseError(
error instanceof Error ? error.message : String(error),
"WRN-OUTPUT-DECLARATION",
);
}
expect("rbrace");
break;
}
case "effect": {
@@ -496,9 +584,24 @@ export function parse(source: string): PageAst {
break;
}
case "ssr":
case "client": {
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
case "client":
case "server": {
const rawMode = kw.value;
const mode: DataMode = rawMode === "client" ? "client" : "ssr";
lx.next();
if (
(rawMode === "client" || rawMode === "server") &&
lx.peek().type === "ident" &&
lx.peek().value === "state"
) {
lx.next();
if (lx.peek().type !== "lbrace")
throw new ParseError(`Expected a grouped ${rawMode} state block`);
states.push(
...parseStateDeclarations(lx.readBalancedBraces(), rawMode as StateRuntime),
);
break;
}
if (mode === "client" && lx.peek().type === "eq") {
lx.next();
hydrate = expect("string").value;
@@ -537,6 +640,14 @@ export function parse(source: string): PageAst {
expect("rbrace");
break;
}
case "shared": {
lx.next();
const member = expect("ident");
if (member.value !== "state")
throw new ParseError(`Expected 'state' after shared at offset ${member.pos}`);
states.push(...parseStateDeclarations(lx.readBalancedBraces(), "shared"));
break;
}
case "realtime": {
lx.next();
const rName = expect("ident").value;
@@ -565,35 +676,51 @@ export function parse(source: string): PageAst {
}
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");
const body = lx.readBalancedBraces();
if (kind === "global-store" || kind === "page-store") {
storeLifecycle = parseStoreLifecycle(body);
const allowedStoreHooks = new Set(["serverInit", "clientInit", "hydrate", "dispose"]);
const hookLexer = new Lexer(body);
while (hookLexer.peek().type !== "eof") {
const token = hookLexer.next();
if (token.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
}
if (!allowedStoreHooks.has(token.value)) {
throw new ParseError(`Unknown store lifecycle hook '${token.value}'`);
}
hookLexer.readBalancedBraces();
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
} else {
const allowedComponentHooks = new Set([
"mount",
"update",
"unmount",
"clientInit",
"dispose",
]);
const hookLexer = new Lexer(body);
while (hookLexer.peek().type !== "eof") {
const token = hookLexer.next();
if (token.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
}
if (!allowedComponentHooks.has(token.value)) {
throw new ParseError(`Unknown lifecycle hook '${token.value}'`);
}
const hookBody = hookLexer.readBalancedBraces();
const hook =
token.value === "clientInit"
? "mount"
: token.value === "dispose"
? "unmount"
: (token.value as LifecycleHookName);
if (lifecycle[hook] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hook}'`);
}
lifecycle[hook] = hookBody;
}
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": {
@@ -611,7 +738,21 @@ export function parse(source: string): PageAst {
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
const body = lx.readBalancedBraces();
functions.push(body);
try {
runtimeFunctions.push(...parseRuntimeFunctions(body));
} catch (error) {
throw new ParseError(
error instanceof Error ? error.message : String(error),
"WRN-FUNCTION-DECLARATION",
);
}
break;
}
case "persist": {
lx.next();
persist = parsePersist(lx.readBalancedBraces());
break;
}
default:
@@ -649,16 +790,36 @@ export function parse(source: string): PageAst {
symbols.add(declaration.name);
}
const outputNames = new Set<string>();
for (const output of outputs) {
if (outputNames.has(output.name))
throw new ParseError(`Duplicate output '${output.name}'`, "WRN-OUTPUT-DUPLICATE");
outputNames.add(output.name);
}
const functionKeys = new Set<string>();
for (const fn of runtimeFunctions) {
const key = `${fn.runtime}:${fn.name}`;
if (functionKeys.has(key))
throw new ParseError(
`Duplicate ${fn.runtime} function '${fn.name}'`,
"WRN-FUNCTION-DUPLICATE",
);
functionKeys.add(key);
}
return {
type: "page",
imports,
structuredImports: parseStructuredImports(imports),
kind,
storeKind,
name,
layout,
layoutIsSymbol,
runtime,
hydrate,
props,
events,
outputs,
types,
states,
computed,
@@ -670,9 +831,12 @@ export function parse(source: string): PageAst {
view,
styles,
functions,
runtimeFunctions,
dataApis,
modeFunctions,
lifecycle,
storeLifecycle,
persist,
watches,
apis,
realtimes,
+26 -2
View File
@@ -1,7 +1,13 @@
/** Canonical, machine-readable WRN language capabilities. */
export const WRN_LANGUAGE_VERSION = "1.0";
export const WRN_LANGUAGE_VERSION = "0.6";
export const WRN_ROOT_KINDS = ["page", "component", "layout"] as const;
export const WRN_ROOT_KINDS = [
"page",
"component",
"layout",
"global-store",
"page-store",
] as const;
export const WRN_ROOT_MEMBERS = [
"layout",
"runtime",
@@ -9,7 +15,10 @@ export const WRN_ROOT_MEMBERS = [
"client",
"types",
"props",
"outputs",
"state",
"shared",
"server",
"computed",
"effect",
"watch",
@@ -24,6 +33,7 @@ export const WRN_ROOT_MEMBERS = [
"realtime",
"style",
"functions",
"persist",
] as const;
export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const;
@@ -47,4 +57,18 @@ export const WRN_DIAGNOSTIC_CODES = {
invalidRuntime: "WRN-RUNTIME-TARGET",
serverInteractive: "WRN-RUNTIME-SERVER-INTERACTIVE",
accessibility: "WRN-A11Y-001",
import: "WRN-IMPORT-001",
function: "WRN-FUNCTION-001",
client: "WRN-CLIENT-001",
server: "WRN-SERVER-001",
output: "WRN-OUTPUT-001",
type: "WRN-TYPE-001",
state: "WRN-STATE-001",
component: "WRN-COMPONENT-001",
template: "WRN-TEMPLATE-001",
store: "WRN-STORE-001",
persist: "WRN-PERSIST-001",
rpc: "WRN-RPC-001",
hydration: "WRN-HYDRATION-001",
migration: "WRN-MIGRATION-001",
} as const;
+10
View File
@@ -19,6 +19,7 @@ export type TokenType =
| "eq"
| "colon"
| "comma"
| "question"
| "eof";
export interface Token {
@@ -87,6 +88,9 @@ export class Lexer {
case ",":
this.pos++;
return { type: "comma", value: c, pos };
case "?":
this.pos++;
return { type: "question", value: c, pos };
case '"':
case "'":
return this.readString(c, pos);
@@ -243,6 +247,7 @@ export class Lexer {
this.pos++;
continue;
}
if (c === "}" && angle === 0 && square === 0 && brace === 0 && paren === 0) break;
if (c === "<") angle++;
else if (c === ">" && angle > 0) angle--;
else if (c === "[") square++;
@@ -253,6 +258,11 @@ export class Lexer {
else if (c === ")" && paren > 0) paren--;
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
if (c === " " || c === "\t") {
let look = this.pos;
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
if (/^[A-Za-z_][A-Za-z0-9_]*\??\s*:/.test(src.slice(look))) break;
}
if (c === "=") {
this.pos++;
const type = value.trim();
+460
View File
@@ -0,0 +1,460 @@
import { Lexer, LexError } from "./tokenizer.ts";
export type FunctionRuntime = "legacy" | "client" | "server" | "shared";
export type StateRuntime = "shared" | "client" | "server";
export type StoreKind = "global" | "page";
export interface FunctionParameterDecl {
name: string;
optional: boolean;
valueType?: string;
default?: string;
}
export interface RuntimeFunctionDecl {
name: string;
runtime: FunctionRuntime;
async: boolean;
parameters: FunctionParameterDecl[];
returnType?: string;
body: string;
source: string;
}
export interface OutputDecl {
name: string;
payload?: {
name: string;
valueType: string;
optional: boolean;
};
}
export interface StructuredImportDecl {
source: string;
typeOnly: boolean;
defaultImport?: string;
namespaceImport?: string;
namedImports: Array<{ imported: string; local: string; typeOnly: boolean }>;
raw: string;
}
export interface PersistDecl {
storage: "memory" | "session" | "local";
include: string[];
version: number;
migrations?: string;
validation?: string;
}
export interface StoreLifecycleDecl {
serverInit?: string;
clientInit?: string;
hydrate?: string;
dispose?: string;
}
function splitTopLevel(input: string, separator = ","): string[] {
const parts: string[] = [];
let start = 0;
let quote: string | null = null;
let angle = 0;
let square = 0;
let brace = 0;
let paren = 0;
for (let i = 0; i < input.length; i++) {
const c = input[i]!;
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
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--;
else if (c === separator && angle === 0 && square === 0 && brace === 0 && paren === 0) {
parts.push(input.slice(start, i).trim());
start = i + 1;
}
}
const tail = input.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function findTopLevelChar(input: string, wanted: string): number {
let quote: string | null = null;
let angle = 0;
let square = 0;
let brace = 0;
let paren = 0;
for (let i = 0; i < input.length; i++) {
const c = input[i]!;
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
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 (c === wanted && angle === 0 && square === 0 && brace === 0 && paren === 0) return i;
}
return -1;
}
function parseParameters(source: string): FunctionParameterDecl[] {
return splitTopLevel(source)
.filter(Boolean)
.map((entry) => {
const eq = findTopLevelChar(entry, "=");
const declaration = (eq >= 0 ? entry.slice(0, eq) : entry).trim();
const defaultValue = eq >= 0 ? entry.slice(eq + 1).trim() : undefined;
const colon = findTopLevelChar(declaration, ":");
const rawName = (colon >= 0 ? declaration.slice(0, colon) : declaration).trim();
const optional = rawName.endsWith("?");
const name = optional ? rawName.slice(0, -1).trim() : rawName;
const valueType = colon >= 0 ? declaration.slice(colon + 1).trim() : undefined;
return {
name,
optional,
...(valueType ? { valueType } : {}),
...(defaultValue ? { default: defaultValue } : {}),
};
});
}
function skipTrivia(source: string, start: number): number {
let i = start;
while (i < source.length) {
if (/\s/.test(source[i]!)) {
i++;
continue;
}
if (source.startsWith("//", i)) {
const end = source.indexOf("\n", i + 2);
i = end < 0 ? source.length : end + 1;
continue;
}
if (source.startsWith("/*", i)) {
const end = source.indexOf("*/", i + 2);
i = end < 0 ? source.length : end + 2;
continue;
}
break;
}
return i;
}
function readWord(source: string, start: number): { word: string; end: number } | null {
const match = /^[A-Za-z_$][\w$]*/.exec(source.slice(start));
return match ? { word: match[0], end: start + match[0].length } : null;
}
function readBalanced(
source: string,
start: number,
open: string,
close: string,
): { inner: string; end: number } {
if (source[start] !== open) throw new Error(`Expected '${open}' at offset ${start}`);
let depth = 0;
let quote: string | null = null;
for (let i = start; i < source.length; i++) {
const c = source[i]!;
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
continue;
}
if (c === open) depth++;
else if (c === close && --depth === 0) return { inner: source.slice(start + 1, i), end: i + 1 };
}
throw new Error(`Unbalanced '${open}${close}' starting at offset ${start}`);
}
export function parseRuntimeFunctions(source: string): RuntimeFunctionDecl[] {
const declarations: RuntimeFunctionDecl[] = [];
let i = 0;
while (i < source.length) {
i = skipTrivia(source, i);
const start = i;
let token = readWord(source, i);
if (!token) {
i++;
continue;
}
let runtime: FunctionRuntime = "legacy";
if (["client", "server", "shared"].includes(token.word)) {
runtime = token.word as FunctionRuntime;
i = skipTrivia(source, token.end);
token = readWord(source, i);
if (!token) continue;
}
let isAsync = false;
if (token.word === "async") {
isAsync = true;
i = skipTrivia(source, token.end);
token = readWord(source, i);
if (!token) continue;
}
if (token.word !== "function") {
i = token.end;
continue;
}
i = skipTrivia(source, token.end);
const nameToken = readWord(source, i);
if (!nameToken) throw new Error(`Expected function name at offset ${i}`);
const name = nameToken.word;
i = skipTrivia(source, nameToken.end);
const params = readBalanced(source, i, "(", ")");
i = skipTrivia(source, params.end);
let returnType: string | undefined;
if (source[i] === ":") {
i++;
const typeStart = i;
let quote: string | null = null;
let angle = 0;
let square = 0;
let paren = 0;
while (i < source.length) {
const c = source[i]!;
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = null;
i++;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "<") angle++;
else if (c === ">" && angle > 0) angle--;
else if (c === "[") square++;
else if (c === "]" && square > 0) square--;
else if (c === "(") paren++;
else if (c === ")" && paren > 0) paren--;
else if (c === "{" && angle === 0 && square === 0 && paren === 0) break;
i++;
}
returnType = source.slice(typeStart, i).trim();
}
i = skipTrivia(source, i);
const body = readBalanced(source, i, "{", "}");
i = body.end;
declarations.push({
name,
runtime,
async: isAsync,
parameters: parseParameters(params.inner),
...(returnType ? { returnType } : {}),
body: body.inner,
source: source.slice(start, body.end).trim(),
});
}
return declarations;
}
export function stripRuntimeFunctionModifiers(source: string, include: FunctionRuntime[]): string {
const allowed = new Set(include);
return parseRuntimeFunctions(source)
.filter((entry) => allowed.has(entry.runtime))
.map((entry) => {
const params = entry.parameters
.map(
(param) =>
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ""}${param.default ? ` = ${param.default}` : ""}`,
)
.join(", ");
return `${entry.async ? "async " : ""}function ${entry.name}(${params})${entry.returnType ? `: ${entry.returnType}` : ""} {${entry.body}}`;
})
.join("\n\n");
}
export function parseOutputs(source: string): OutputDecl[] {
const out: OutputDecl[] = [];
let i = 0;
while (i < source.length) {
i = skipTrivia(source, i);
if (i >= source.length) break;
const nameToken = readWord(source, i);
if (!nameToken) throw new Error(`Expected output name at offset ${i}`);
i = skipTrivia(source, nameToken.end);
const args = readBalanced(source, i, "(", ")");
i = args.end;
const parameters = parseParameters(args.inner);
if (parameters.length > 1)
throw new Error(`Output '${nameToken.word}' accepts zero or one payload`);
const payload = parameters[0];
if (payload && !payload.valueType)
throw new Error(`Output '${nameToken.word}' payload requires a type`);
out.push({
name: nameToken.word,
...(payload
? {
payload: {
name: payload.name,
valueType: payload.valueType!,
optional: payload.optional,
},
}
: {}),
});
}
return out;
}
export function parseStructuredImports(imports: string[]): StructuredImportDecl[] {
return imports.map((raw) => {
const sourceMatch = /\sfrom\s+["']([^"']+)["']|^import\s+["']([^"']+)["']/.exec(raw);
const source = sourceMatch?.[1] ?? sourceMatch?.[2] ?? "";
const typeOnly = /^import\s+type\b/.test(raw);
const clause = raw
.replace(/^import\s+(?:type\s+)?/, "")
.replace(/\s+from\s+["'][^"']+["']\s*;?$/, "")
.trim();
const declaration: StructuredImportDecl = { source, typeOnly, namedImports: [], raw };
if (!clause || clause.startsWith('"') || clause.startsWith("'")) return declaration;
if (clause.startsWith("*")) {
declaration.namespaceImport = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
return declaration;
}
let rest = clause;
if (!rest.startsWith("{")) {
const comma = findTopLevelChar(rest, ",");
declaration.defaultImport = (comma < 0 ? rest : rest.slice(0, comma)).trim();
rest = comma < 0 ? "" : rest.slice(comma + 1).trim();
}
const named = /^\{([\s\S]*)\}$/.exec(rest)?.[1];
if (named !== undefined) {
declaration.namedImports = splitTopLevel(named).map((item) => {
const localTypeOnly = /^type\s+/.test(item);
const cleaned = item.replace(/^type\s+/, "").trim();
const [imported, local] = cleaned.split(/\s+as\s+/);
return {
imported: imported!.trim(),
local: (local ?? imported)!.trim(),
typeOnly: typeOnly || localTypeOnly,
};
});
}
return declaration;
});
}
export function parseStateDeclarations(
source: string,
runtime: StateRuntime,
): Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> {
const lx = new Lexer(source);
const out: Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> = [];
while (lx.peek().type !== "eof") {
const nameToken = lx.next();
if (nameToken.type !== "ident")
throw new LexError(`Expected a state name at offset ${nameToken.pos}`);
let valueType: string | undefined;
if (lx.peek().type === "colon") {
lx.next();
const annotation = lx.readTypeAnnotation();
valueType = annotation.type;
if (!annotation.hasDefault)
throw new LexError(`State '${nameToken.value}' requires an initializer`);
} else {
const eq = lx.next();
if (eq.type !== "eq")
throw new LexError(`Expected '=' after state '${nameToken.value}' at offset ${eq.pos}`);
}
out.push({
name: nameToken.value,
...(valueType ? { valueType } : {}),
expr: lx.readPropInitializer(),
runtime,
});
}
return out;
}
export function parseComputedDeclarations(
source: string,
): Array<{ name: string; valueType?: string; expr: string }> {
const lx = new Lexer(source);
const out: Array<{ name: string; valueType?: string; expr: string }> = [];
while (lx.peek().type !== "eof") {
const nameToken = lx.next();
if (nameToken.type !== "ident")
throw new LexError(`Expected a computed name at offset ${nameToken.pos}`);
let valueType: string | undefined;
if (lx.peek().type === "colon") {
lx.next();
const annotation = lx.readTypeAnnotation();
valueType = annotation.type;
if (!annotation.hasDefault)
throw new LexError(`Computed '${nameToken.value}' requires an expression`);
} else {
const eq = lx.next();
if (eq.type !== "eq")
throw new LexError(`Expected '=' after computed '${nameToken.value}' at offset ${eq.pos}`);
}
out.push({
name: nameToken.value,
...(valueType ? { valueType } : {}),
expr: lx.readPropInitializer(),
});
}
return out;
}
function nestedBlock(source: string, name: string): string | undefined {
const match = new RegExp(`\\b${name}\\s*\\{`).exec(source);
if (!match) return undefined;
const brace = source.indexOf("{", match.index);
return readBalanced(source, brace, "{", "}").inner.trim() || undefined;
}
export function parsePersist(source: string): PersistDecl {
const storage = /\bstorage\s*=\s*["'](memory|session|local)["']/.exec(source)?.[1] as
PersistDecl["storage"] | undefined;
const includeRaw = /\binclude\s*=\s*\[([\s\S]*?)\]/.exec(source)?.[1] ?? "";
const include = Array.from(includeRaw.matchAll(/["']([^"']+)["']/g), (match) => match[1]!);
const version = Number(/\bversion\s*=\s*(\d+)/.exec(source)?.[1] ?? "1");
const migrations = nestedBlock(source, "migrations");
const validation = nestedBlock(source, "validate");
return {
storage: storage ?? "memory",
include,
version,
...(migrations ? { migrations } : {}),
...(validation ? { validation } : {}),
};
}
export function parseStoreLifecycle(source: string): StoreLifecycleDecl {
const out: StoreLifecycleDecl = {};
for (const hook of ["serverInit", "clientInit", "hydrate", "dispose"] as const) {
const start = new RegExp(`\\b${hook}\\s*\\{`).exec(source);
if (!start) continue;
const brace = source.indexOf("{", start.index);
out[hook] = readBalanced(source, brace, "{", "}").inner;
}
return out;
}
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import { diagnose, parse } from "../src/index.ts";
const source = `import type { PublicUser } from "@/types/user.ts"
import PublicLayout from "@/layouts/PublicLayout.wrn"
component ProfileCard {
props {
user: PublicUser
title?: string
open: boolean = false
size: "small" | "medium" | "large" = "medium"
}
state query: string = ""
state {
selected: PublicUser | null = null
loading: boolean = false
}
client state { menuOpen: boolean = false }
server state { sessionId: string | null = null }
computed ready: boolean = selected !== null
computed { label: string = title || user.name }
outputs {
confirm(payload: PublicUser)
cancel()
}
functions {
client async function confirmed(payload: PublicUser): Promise<void> {
const result = await server.confirmed(payload)
output.confirm(result)
}
server async function confirmed(payload: PublicUser): Promise<PublicUser> {
return payload
}
shared function normalize(value: string): string {
return value.trim()
}
}
view { <button>{label}</button> }
}`;
test("parses the WRNexusJS 0.6 language surface", () => {
const ast = parse(source);
expect(ast.structuredImports).toHaveLength(2);
expect(ast.props.map((prop) => [prop.name, prop.required])).toEqual([
["user", true],
["title", false],
["open", false],
["size", false],
]);
expect(ast.states.map((state) => `${state.runtime}:${state.name}`)).toEqual([
"shared:query",
"shared:selected",
"shared:loading",
"client:menuOpen",
"server:sessionId",
]);
expect(ast.outputs.map((output) => output.name)).toEqual(["confirm", "cancel"]);
expect(ast.runtimeFunctions.map((fn) => `${fn.runtime}:${fn.name}`)).toEqual([
"client:confirmed",
"server:confirmed",
"shared:normalize",
]);
});
test("supports global and page store roots", () => {
const globalStore = parse(`global store UserStore {
state { user: string | null = null }
computed { authenticated: boolean = user !== null }
persist { storage = "local" include = ["user"] version = 1 }
lifecycle { serverInit {} clientInit {} hydrate {} dispose {} }
functions { client function clear(): void { user = null } }
}`);
const pageStore = parse(`page store SearchStore { state { query: string = "" } }`);
expect(globalStore.kind).toBe("global-store");
expect(globalStore.persist?.storage).toBe("local");
expect(globalStore.storeLifecycle).toEqual(
expect.objectContaining({ serverInit: "", dispose: "" }),
);
expect(pageStore.kind).toBe("page-store");
});
test("rejects duplicate implementations in the same runtime", () => {
const diagnostics = diagnose(`component Invalid {
functions {
client function save(): void {}
client function save(): void {}
}
view { <div></div> }
}`);
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-FUNCTION-DUPLICATE")).toBe(true);
});
test("readonly prop diagnostics ignore comparisons, strings, and shadowing parameters", () => {
const diagnostics = diagnose(`component Valid {
props { name: string = "field" mode: string = "exact" items: any[] = [] }
functions {
client function inspect(name, localItems) {
if (mode === "exact") localItems = items.filter((item) => item.name === name)
return document.querySelector("input[name='" + name + "']")
}
}
view { <div></div> }
}`);
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-PROP-READONLY")).toBe(false);
});
test("readonly prop diagnostics still reject direct prop assignments", () => {
const diagnostics = diagnose(`component Invalid {
props { open: boolean = false }
functions { client function mutate() { open = true } }
view { <div></div> }
}`);
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-PROP-READONLY")).toBe(true);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.5.14",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@wrnexus/typecheck",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./contracts": "./src/contracts.ts",
"./project": "./src/project.ts"
},
"dependencies": {
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.5.0"
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { PageAst } from "@wrnexus/syntax";
function safe(name: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
}
export function componentContract(ast: PageAst): string {
const typeSource = ast.types
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n\n");
const props = ast.props
.map(
(prop) =>
` readonly ${safe(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
)
.join("\n");
const outputs = ast.outputs
.map(
(output) =>
` ${safe(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
)
.join("\n");
const callable = ast.runtimeFunctions
.filter((fn) => fn.runtime !== "legacy")
.map(
(fn) =>
` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
return `${typeSource ? `${typeSource}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}Functions {\n${callable}\n}\n\nexport interface ${ast.name}Contract {\n props: ${ast.name}Props;\n outputs: ${ast.name}Outputs;\n functions: ${ast.name}Functions;\n}\n`;
}
export function storeContract(ast: PageAst): string {
const state = ast.states
.filter((entry) => entry.runtime !== "server")
.map((entry) => ` readonly ${safe(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const computed = ast.computed
.map((entry) => ` readonly ${safe(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const actions = ast.runtimeFunctions
.filter((fn) => fn.runtime !== "server")
.map(
(fn) =>
` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
return `export interface ${ast.name}State {\n${state}\n}\n\nexport interface ${ast.name}Computed {\n${computed}\n}\n\nexport interface ${ast.name}Actions {\n${actions}\n}\n\nexport interface ${ast.name}Instance extends ${ast.name}State, ${ast.name}Computed, ${ast.name}Actions {\n reset(): void;\n snapshot(): Readonly<${ast.name}State>;\n}\n`;
}
+646
View File
@@ -0,0 +1,646 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, normalize, resolve } from "node:path";
import ts from "typescript";
import {
containsReadonlyPropMutation,
inferredRuntimeType,
parse,
runtimeTypeOf,
type PageAst,
type RuntimeFunctionDecl,
type ViewNode,
} from "@wrnexus/syntax";
import { componentContract, storeContract } from "./contracts.ts";
import { findAppRoot, loadApplicationTypes } from "./project.ts";
export { componentContract, storeContract } from "./contracts.ts";
export { findAppRoot, loadApplicationTypes } from "./project.ts";
export interface WrnTypeDiagnostic {
code: string;
category: "error" | "warning" | "info";
message: string;
file: string;
line: number;
column: number;
length: number;
expected?: string;
received?: string;
hint?: string;
related?: { file: string; line: number; column: number; message: string };
}
export interface TypecheckOptions {
filePath?: string;
appRoot?: string;
strict?: boolean;
noImplicitAny?: boolean;
strictNullChecks?: boolean;
checkRuntimeBoundaries?: boolean;
}
interface SourceMapping {
virtualStartLine: number;
virtualEndLine: number;
sourceStartLine: number;
sourceStartColumn: number;
}
function resolveImportedWrn(source: string, filePath: string, appRoot: string): string | null {
if (source.startsWith("@/")) return resolve(appRoot, "app", source.slice(2));
if (source.startsWith(".")) return resolve(dirname(filePath), source);
return null;
}
function importedWrnDeclarations(ast: PageAst, filePath: string, appRoot: string): string {
const declarations: string[] = [];
for (const entry of ast.structuredImports) {
if (entry.typeOnly || !entry.source.endsWith(".wrn")) continue;
const importedPath = resolveImportedWrn(entry.source, filePath, appRoot);
if (!importedPath || !existsSync(importedPath)) continue;
try {
const importedAst = parse(readFileSync(importedPath, "utf8"));
const isStore = importedAst.kind === "global-store" || importedAst.kind === "page-store";
declarations.push(isStore ? storeContract(importedAst) : componentContract(importedAst));
const typeName = isStore ? `${importedAst.name}Instance` : `${importedAst.name}Contract`;
if (entry.defaultImport)
declarations.push(`declare const ${entry.defaultImport}: ${typeName};`);
if (entry.namespaceImport)
declarations.push(`declare const ${entry.namespaceImport}: Record<string, unknown>;`);
for (const item of entry.namedImports) {
declarations.push(
`declare const ${item.local}: ${item.imported === importedAst.name ? typeName : "unknown"};`,
);
}
} catch {
// The parser/import diagnostic layer reports malformed or unresolved .wrn imports.
}
}
return declarations.join("\n\n");
}
export interface VirtualTypeScriptModule {
ast: PageAst;
fileName: string;
code: string;
mappings: SourceMapping[];
}
function lineAt(source: string, needle: string, occurrence = 0): { line: number; column: number } {
let index = -1;
let from = 0;
for (let count = 0; count <= occurrence; count++) {
index = source.indexOf(needle, from);
if (index < 0) return { line: 1, column: 1 };
from = index + Math.max(needle.length, 1);
}
const before = source.slice(0, index);
const parts = before.split(/\r?\n/);
return { line: parts.length, column: (parts.at(-1)?.length ?? 0) + 1 };
}
function runtimeNamespace(runtime: RuntimeFunctionDecl["runtime"]): string {
return `__wrn_${runtime}`;
}
function functionDeclaration(fn: RuntimeFunctionDecl): string {
const params = fn.parameters
.map(
(param) =>
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ": unknown"}${param.default ? ` = ${param.default}` : ""}`,
)
.join(", ");
return `export ${fn.async ? "async " : ""}function ${fn.name}(${params})${fn.returnType ? `: ${fn.returnType}` : ""} {${fn.body}}`;
}
function retainedImports(ast: PageAst): string {
return ast.structuredImports
.filter((entry) => {
if (entry.source.endsWith(".wrn")) return false;
if (entry.typeOnly) return true;
return (
!entry.source.startsWith("@/components/") &&
!entry.source.startsWith("@/layouts/") &&
!entry.source.startsWith("@/stores/")
);
})
.map((entry) => entry.raw)
.join("\n");
}
export function virtualTypeScriptModule(
source: string,
filePath = "component.wrn",
appRoot = findAppRoot(filePath),
): VirtualTypeScriptModule {
const ast = parse(source);
const chunks: string[] = [];
const mappings: SourceMapping[] = [];
let virtualLine = 1;
const append = (code: string, sourceNeedle?: string, occurrence = 0): void => {
if (!code) return;
const lineCount = code.split(/\r?\n/).length;
if (sourceNeedle) {
const sourcePosition = lineAt(source, sourceNeedle, occurrence);
mappings.push({
virtualStartLine: virtualLine,
virtualEndLine: virtualLine + lineCount - 1,
sourceStartLine: sourcePosition.line,
sourceStartColumn: sourcePosition.column,
});
}
chunks.push(code);
virtualLine += lineCount;
};
append(retainedImports(ast), ast.imports[0]);
append(ast.types.join("\n\n"), ast.types[0]?.trim());
for (const prop of ast.props) {
append(`declare const ${prop.name}: Readonly<${prop.valueType ?? "unknown"}>;`, prop.name);
}
for (const state of ast.states) {
append(
`let ${state.name}${state.valueType ? `: ${state.valueType}` : ""} = (${state.expr});`,
state.name,
);
}
for (const computed of ast.computed) {
append(
`const ${computed.name}${computed.valueType ? `: ${computed.valueType}` : ""} = (${computed.expr});`,
computed.name,
);
}
const outputType = ast.outputs
.map(
(output) =>
`${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`,
)
.join("; ");
const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server");
const serverType = serverFunctions
.map(
(fn) =>
`${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`,
)
.join("; ");
append(`declare const output: { ${outputType} };`);
append(`declare const server: { ${serverType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;");
append(
ast.kind === "global-store" || ast.kind === "page-store"
? storeContract(ast)
: componentContract(ast),
);
append(importedWrnDeclarations(ast, filePath, appRoot));
const sharedNames = new Set(
ast.runtimeFunctions.filter((fn) => fn.runtime === "shared").map((fn) => fn.name),
);
for (const runtime of ["shared", "client", "server", "legacy"] as const) {
const functions = ast.runtimeFunctions.filter((fn) => fn.runtime === runtime);
if (!functions.length) continue;
const localNames = new Set(functions.map((fn) => fn.name));
const sharedAliases =
runtime !== "shared"
? [...sharedNames]
.filter((name) => !localNames.has(name))
.map((name) => `const ${name} = ${runtimeNamespace("shared")}.${name};`)
.join("\n")
: "";
append(`namespace ${runtimeNamespace(runtime)} {\n${sharedAliases}`);
for (const fn of functions) append(functionDeclaration(fn), fn.source);
append("}");
}
return {
ast,
fileName: filePath.replace(/\.wrn$/i, ".wrn.ts"),
code: chunks.join("\n") + "\n",
mappings,
};
}
interface ComponentShape {
name: string;
props: Array<{ name: string; type: string; required: boolean; options?: string[] }>;
outputs: Array<{ name: string; payloadType?: string }>;
}
function shapeFromAst(ast: PageAst): ComponentShape {
return {
name: ast.name,
props: ast.props.map((prop) => ({
name: prop.name,
type: prop.valueType ?? "unknown",
required: prop.required,
options: /^\s*(?:"[^"]+"\s*\|\s*)+"[^"]+"\s*$/.test(prop.valueType ?? "")
? (prop.valueType ?? "").split("|").map((part) => part.trim().replace(/^"|"$/g, ""))
: undefined,
})),
outputs: ast.outputs.map((output) => ({
name: output.name,
payloadType: output.payload?.valueType,
})),
};
}
function loadUiShapes(appRoot: string): Map<string, ComponentShape> {
const candidates = [
join(appRoot, "node_modules", "@wrnexus", "ui", "component-reference.json"),
resolve(process.cwd(), "packages", "ui", "component-reference.json"),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
components?: Array<{
name: string;
props?: Array<{ name: string; type?: string; required?: boolean; options?: string[] }>;
outputs?: Array<{ name: string; payloadType?: string }>;
}>;
};
return new Map(
(parsed.components ?? []).map((component) => [
component.name,
{
name: component.name,
props: (component.props ?? []).map((prop) => ({
name: prop.name,
type: prop.type ?? "unknown",
required: Boolean(prop.required),
options: prop.options,
})),
outputs: component.outputs ?? [],
},
]),
);
} catch {
// Ignore a stale or malformed optional reference; import diagnostics report it separately.
}
}
return new Map();
}
function importedComponentShapes(
ast: PageAst,
filePath: string,
appRoot: string,
): Map<string, ComponentShape> {
const shapes = new Map<string, ComponentShape>();
const ui = loadUiShapes(appRoot);
for (const entry of ast.structuredImports) {
if (entry.typeOnly) continue;
if (entry.source === "@wrnexus/ui") {
for (const item of entry.namedImports) {
const shape = ui.get(item.imported);
if (shape) shapes.set(item.local, shape);
}
continue;
}
if (!entry.source.endsWith(".wrn")) continue;
const importedPath = resolveImportedWrn(entry.source, filePath, appRoot);
if (!importedPath || !existsSync(importedPath)) continue;
try {
const importedAst = parse(readFileSync(importedPath, "utf8"));
if (importedAst.kind !== "component") continue;
const shape = shapeFromAst(importedAst);
if (entry.defaultImport) shapes.set(entry.defaultImport, shape);
for (const item of entry.namedImports)
if (item.imported === importedAst.name) shapes.set(item.local, shape);
} catch {
// Syntax/import diagnostics are emitted elsewhere.
}
}
return shapes;
}
function walkView(
nodes: ViewNode[],
visit: (node: Extract<ViewNode, { type: "element" }>) => void,
): void {
for (const node of nodes) {
if (node.type === "element") {
visit(node);
walkView(node.children, visit);
} else if (node.type === "each") {
walkView(node.body, visit);
walkView(node.empty, visit);
} else if (node.type === "if") {
for (const branch of node.branches) walkView(branch.body, visit);
}
}
}
function componentUsageDiagnostics(
source: string,
ast: PageAst,
filePath: string,
appRoot: string,
): WrnTypeDiagnostic[] {
const shapes = importedComponentShapes(ast, filePath, appRoot);
const diagnostics: WrnTypeDiagnostic[] = [];
walkView(ast.view, (node) => {
const shape = shapes.get(node.tag);
if (!shape) return;
const attributes = new Map(
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]),
);
const outputNames = new Set(shape.outputs.map((output) => output.name));
const position = lineAt(source, `<${node.tag}`);
for (const prop of shape.props) {
if (prop.required && !attributes.has(prop.name)) {
diagnostics.push({
code: "WRN-COMPONENT-MISSING-PROP",
category: "error",
message: `<${node.tag}> is missing required prop '${prop.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: node.tag.length + 1,
expected: prop.type,
hint: `Add ${prop.name} with a value assignable to ${prop.type}.`,
});
}
}
const known = new Map(shape.props.map((prop) => [prop.name, prop]));
for (const attr of node.attrs) {
if (attr.event) {
if (!outputNames.has(attr.name)) {
diagnostics.push({
code: "WRN-OUTPUT-UNKNOWN-HANDLER",
category: "error",
message: `<${node.tag}> does not declare output '${attr.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
hint: "Use an output declared by the component contract.",
});
}
continue;
}
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
const prop = known.get(attr.name);
if (!prop) {
diagnostics.push({
code: "WRN-COMPONENT-UNKNOWN-PROP",
category: "error",
message: `<${node.tag}> has unknown prop '${attr.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
hint: "Remove the prop or add it to the component declaration.",
});
continue;
}
if (attr.value.startsWith("{") && attr.value.endsWith("}")) continue;
const received = attr.boolean ? "boolean" : inferredRuntimeType(JSON.stringify(attr.value));
const expected = runtimeTypeOf(prop.type);
if (expected !== "unknown" && received !== "unknown" && expected !== received) {
diagnostics.push({
code: "WRN-COMPONENT-PROP-TYPE",
category: "error",
message: `Prop '${attr.name}' on <${node.tag}> expects ${prop.type}, received ${received}.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
expected: prop.type,
received,
});
}
if (prop.options?.length && !prop.options.includes(attr.value)) {
diagnostics.push({
code: "WRN-COMPONENT-PROP-LITERAL",
category: "error",
message: `Prop '${attr.name}' on <${node.tag}> must be one of ${prop.options.map((value) => JSON.stringify(value)).join(", ")}.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
expected: prop.type,
received: JSON.stringify(attr.value),
});
}
}
});
return diagnostics;
}
function category(value: ts.DiagnosticCategory): WrnTypeDiagnostic["category"] {
return value === ts.DiagnosticCategory.Error
? "error"
: value === ts.DiagnosticCategory.Warning
? "warning"
: "info";
}
function hostWithVirtualFiles(
files: Map<string, string>,
options: ts.CompilerOptions,
): ts.CompilerHost {
const host = ts.createCompilerHost(options, true);
const originalGet = host.getSourceFile.bind(host);
host.fileExists = (fileName) => files.has(normalize(fileName)) || ts.sys.fileExists(fileName);
host.readFile = (fileName) => files.get(normalize(fileName)) ?? ts.sys.readFile(fileName);
host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
const text = files.get(normalize(fileName));
return text === undefined
? originalGet(fileName, languageVersion, onError, shouldCreateNewSourceFile)
: ts.createSourceFile(fileName, text, languageVersion, true, ts.ScriptKind.TS);
};
return host;
}
function mappedPosition(
virtual: VirtualTypeScriptModule,
line: number,
column: number,
): { line: number; column: number } {
const mapping = virtual.mappings.find(
(entry) => line >= entry.virtualStartLine && line <= entry.virtualEndLine,
);
if (!mapping) return { line: 1, column: 1 };
const offset = line - mapping.virtualStartLine;
return {
line: mapping.sourceStartLine + offset,
column: offset === 0 ? mapping.sourceStartColumn + Math.max(column - 1, 0) : column,
};
}
function runtimeDiagnostics(source: string, ast: PageAst, filePath: string): WrnTypeDiagnostic[] {
const diagnostics: WrnTypeDiagnostic[] = [];
const duplicateKeys = new Map<string, RuntimeFunctionDecl>();
for (const fn of ast.runtimeFunctions) {
const key = `${fn.runtime}:${fn.name}`;
const previous = duplicateKeys.get(key);
if (previous) {
const position = lineAt(source, fn.source);
const related = lineAt(source, previous.source);
diagnostics.push({
code: "WRN-FUNCTION-DUPLICATE",
category: "error",
message: `Duplicate ${fn.runtime} implementation for '${fn.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Keep only one implementation for each function name and runtime.",
related: {
file: filePath,
line: related.line,
column: related.column,
message: "First implementation is here.",
},
});
} else duplicateKeys.set(key, fn);
const position = lineAt(source, fn.source);
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
for (const prop of ast.props) {
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
diagnostics.push({
code: "WRN-PROP-READONLY",
category: "error",
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Copy the prop into state or call a component output instead.",
});
}
}
if (fn.runtime === "server" && /\boutput\s*\./.test(fn.body)) {
diagnostics.push({
code: "WRN-OUTPUT-SERVER-CALL",
category: "error",
message: `Server function '${fn.name}' cannot call a component output.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Return a typed value to the client function and call output.name(payload) there.",
});
}
if (
fn.runtime === "client" &&
/\b(?:Bun|process|Deno|ctx\.(?:db|request|req))\b/.test(fn.body)
) {
diagnostics.push({
code: "WRN-CLIENT-SERVER-API",
category: "error",
message: `Client function '${fn.name}' references a server-only API.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Move server work to a server function and call it through server.name(...).",
});
}
if (
fn.runtime === "server" &&
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
) {
diagnostics.push({
code: "WRN-SERVER-BROWSER-API",
category: "error",
message: `Server function '${fn.name}' references a browser-only API.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Move browser work to a client function.",
});
}
}
return diagnostics;
}
export function checkWrnSource(
source: string,
options: TypecheckOptions = {},
): WrnTypeDiagnostic[] {
const filePath = resolve(options.filePath ?? "component.wrn");
const appRoot = options.appRoot ?? findAppRoot(filePath);
let virtual: VirtualTypeScriptModule;
try {
virtual = virtualTypeScriptModule(source, filePath, appRoot);
} catch (error) {
return [
{
code: "WRN-TYPE-PARSE",
category: "error",
message: error instanceof Error ? error.message : String(error),
file: filePath,
line: 1,
column: 1,
length: 1,
},
];
}
const appTypes = loadApplicationTypes(appRoot);
const files = new Map<string, string>();
files.set(normalize(virtual.fileName), virtual.code);
for (const [name, text] of appTypes.files) files.set(normalize(name), text);
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: options.strict ?? true,
noImplicitAny: options.noImplicitAny ?? true,
strictNullChecks: options.strictNullChecks ?? true,
skipLibCheck: true,
allowImportingTsExtensions: true,
allowArbitraryExtensions: true,
noEmit: true,
baseUrl: appRoot,
paths: { "@/*": ["app/*"] },
lib: ["lib.esnext.d.ts", "lib.dom.d.ts"],
};
const rootNames = [virtual.fileName, ...appTypes.files.keys()];
const program = ts.createProgram(
rootNames,
compilerOptions,
hostWithVirtualFiles(files, compilerOptions),
);
const tsDiagnostics = ts.getPreEmitDiagnostics(program).map((diagnostic): WrnTypeDiagnostic => {
const file = diagnostic.file;
const start = diagnostic.start ?? 0;
const virtualPosition = file?.getLineAndCharacterOfPosition(start) ?? { line: 0, character: 0 };
const isVirtual = normalize(file?.fileName ?? "") === normalize(virtual.fileName);
const sourcePosition = isVirtual
? mappedPosition(virtual, virtualPosition.line + 1, virtualPosition.character + 1)
: { line: virtualPosition.line + 1, column: virtualPosition.character + 1 };
return {
code: `WRN-TYPE-${diagnostic.code}`,
category: category(diagnostic.category),
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
file: isVirtual ? filePath : (file?.fileName ?? filePath),
line: sourcePosition.line,
column: sourcePosition.column,
length: diagnostic.length ?? 1,
hint: "Fix the TypeScript contract or expression in the related .wrn declaration.",
};
});
return [
...(options.checkRuntimeBoundaries === false
? []
: runtimeDiagnostics(source, virtual.ast, filePath)),
...componentUsageDiagnostics(source, virtual.ast, filePath, appRoot),
...tsDiagnostics,
];
}
export function checkWrnFile(
filePath: string,
options: Omit<TypecheckOptions, "filePath"> = {},
): WrnTypeDiagnostic[] {
return checkWrnSource(readFileSync(filePath, "utf8"), { ...options, filePath });
}
+37
View File
@@ -0,0 +1,37 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
export interface ApplicationTypes {
root: string;
globalFile?: string;
files: Map<string, string>;
}
function walk(dir: string, out: string[]): void {
if (!existsSync(dir)) return;
for (const name of readdirSync(dir)) {
const path = join(dir, name);
const stat = statSync(path);
if (stat.isDirectory()) walk(path, out);
else if (/\.(?:ts|d\.ts)$/.test(name)) out.push(path);
}
}
export function findAppRoot(filePath: string): string {
let current = resolve(dirname(filePath));
while (true) {
if (existsSync(join(current, "app"))) return current;
const parent = dirname(current);
if (parent === current) return resolve(dirname(filePath));
current = parent;
}
}
export function loadApplicationTypes(appRoot: string): ApplicationTypes {
const typesRoot = join(appRoot, "app", "types");
const paths: string[] = [];
walk(typesRoot, paths);
const files = new Map(paths.map((path) => [path, readFileSync(path, "utf8")]));
const globalFile = join(typesRoot, "global.d.ts");
return { root: typesRoot, ...(files.has(globalFile) ? { globalFile } : {}), files };
}
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkWrnSource } from "../src/index.ts";
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), "wrn-v060-contracts-"));
mkdirSync(join(root, "app", "components"), { recursive: true });
mkdirSync(join(root, "app", "stores", "global"), { recursive: true });
mkdirSync(join(root, "app", "types"), { recursive: true });
writeFileSync(
join(root, "app", "types", "global.d.ts"),
"declare interface AppMarker { ready: boolean }\n",
);
writeFileSync(
join(root, "app", "components", "UserCard.wrn"),
`component UserCard {
props { name: string role: "admin" | "member" = "member" }
outputs { select(payload: string) }
view { <button>{name}</button> }
}`,
);
writeFileSync(
join(root, "app", "stores", "global", "counter.wrn"),
`global store CounterStore {
state { count: number = 0 }
functions { client function increment(amount: number): void { count += amount } }
}`,
);
return root;
}
test("checks imported component props and output names", () => {
const root = fixture();
const filePath = join(root, "app", "pages", "home.wrn");
const diagnostics = checkWrnSource(
`import UserCard from "@/components/UserCard.wrn"
page Home {
view { <UserCard role="owner" unknown="x" @missing='noop(payload)' /> }
}`,
{ appRoot: root, filePath },
);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-MISSING-PROP")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-UNKNOWN-PROP")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-PROP-LITERAL")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-OUTPUT-UNKNOWN-HANDLER")).toBe(true);
});
test("types imported store actions", () => {
const root = fixture();
const filePath = join(root, "app", "pages", "home.wrn");
const diagnostics = checkWrnSource(
`import counterStore from "@/stores/global/counter.wrn"
page Home {
functions { client function update(): void { counterStore.increment("wrong") } }
view { <button></button> }
}`,
{ appRoot: root, filePath },
);
expect(diagnostics.some((item) => item.code === "WRN-TYPE-2345")).toBe(true);
});
+57
View File
@@ -0,0 +1,57 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkWrnSource } from "../src/index.ts";
function app(): string {
const root = mkdtempSync(join(tmpdir(), "wrn-typecheck-"));
mkdirSync(join(root, "app", "types"), { recursive: true });
writeFileSync(
join(root, "app", "types", "user.ts"),
"export interface User { id: string; name: string }\n",
);
writeFileSync(
join(root, "app", "types", "global.d.ts"),
"declare interface RequestError { message: string }\n",
);
return root;
}
test("loads app types and maps output payload errors back to WRN source", () => {
const root = app();
const source = `import type { User } from "@/types/user.ts"
component Demo {
props { user: User }
outputs { confirm(payload: User) }
functions {
client function save(value: User): void {
output.confirm({ id: 1, name: value.name })
}
}
view { <button></button> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "components", "Demo.wrn"),
});
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2322")).toBe(true);
expect(diagnostics.find((diagnostic) => diagnostic.code === "WRN-TYPE-2322")?.line).toBe(7);
});
test("allows the same function name in client and server runtimes", () => {
const root = app();
const diagnostics = checkWrnSource(
`component Demo {
functions {
client async function save(value: string): Promise<void> { await server.save(value) }
server async function save(value: string): Promise<void> {}
}
view { <div></div> }
}`,
{ appRoot: root, filePath: join(root, "app", "components", "Demo.wrn") },
);
expect(
diagnostics.filter((diagnostic) => diagnostic.code === "WRN-FUNCTION-DUPLICATE"),
).toHaveLength(0);
});
+169 -169
View File
File diff suppressed because it is too large Load Diff
+423 -438
View File
@@ -1,91 +1,46 @@
{
"sourceDocuments": [
"Screenshot-directed WRNexus UI catalog reset"
],
"sourceDocuments": ["Screenshot-directed WRNexus UI catalog reset"],
"components": [
{
"name": "AdvancedSelect",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive advanced select component.",
"events": [
"search",
"select",
"change",
"clear",
"open",
"close",
"load",
"error"
]
},
{
"name": "ComboBox",
"category": "advanced-forms",
"purpose": "Editable autocomplete combobox with local and remote suggestions.",
"events": [
"search",
"select",
"change",
"clear",
"open",
"close",
"load",
"error"
]
},
{
"name": "CopyMarkup",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive copy markup component."
},
{
"name": "InputNumber",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive input number component."
},
{
"name": "PinInput",
"category": "advanced-forms",
"purpose": "Secure multi-cell PIN and verification-code input with regex and paste support.",
"events": [
"input",
"change",
"complete",
"paste",
"clear",
"error"
]
},
{
"name": "StrongPassword",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive strong password component."
},
{
"name": "ToggleCount",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive toggle count component."
},
{
"name": "TogglePassword",
"category": "advanced-forms",
"purpose": "Accessible password field with optional show and hide controls.",
"events": [
"input",
"change",
"toggle"
]
},
{
"name": "Accordion",
"category": "base",
"purpose": "Theme-aware, responsive accordion component."
},
{
"name": "AdvancedDatePicker",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced date picker component."
},
{
"name": "AdvancedRangeSlider",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced range slider component."
},
{
"name": "AdvancedSelect",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive advanced select component."
},
{
"name": "Alert",
"category": "base",
"purpose": "Theme-aware, responsive alert component."
},
{
"name": "AnnouncementBar",
"category": "marketing",
"purpose": "Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls."
},
{
"name": "AuthForm",
"category": "core",
"purpose": "Reusable auth form component."
},
{
"name": "AuthSplitLayout",
"category": "core",
"purpose": "Reusable auth split layout component."
},
{
"name": "Avatar",
"category": "base",
@@ -96,6 +51,11 @@
"category": "base",
"purpose": "Theme-aware, responsive avatar group component."
},
{
"name": "BackToTop",
"category": "navigation",
"purpose": "Provide a responsive floating control that returns long pages to the top and can show scroll progress."
},
{
"name": "Badge",
"category": "base",
@@ -106,6 +66,11 @@
"category": "base",
"purpose": "Theme-aware, responsive blockquote component."
},
{
"name": "Breadcrumb",
"category": "navigation",
"purpose": "Show responsive hierarchical navigation with home support, separators, current-page state, sizes, and selection events."
},
{
"name": "Button",
"category": "base",
@@ -126,191 +91,76 @@
"category": "base",
"purpose": "Theme-aware, responsive carousel component."
},
{
"name": "Chart",
"category": "integrations",
"purpose": "Theme-aware, responsive chart component."
},
{
"name": "ChatBubble",
"category": "base",
"purpose": "Theme-aware, responsive chat bubble component."
},
{
"name": "Collapse",
"category": "base",
"purpose": "Theme-aware, responsive collapse component."
},
{
"name": "DatePicker",
"category": "base",
"purpose": "Theme-aware, responsive date picker component."
},
{
"name": "DeviceFrame",
"category": "base",
"purpose": "Theme-aware, responsive device frame component."
},
{
"name": "FileUploadProgress",
"category": "base",
"purpose": "Theme-aware, responsive file upload progress component."
},
{
"name": "LegendIndicator",
"category": "base",
"purpose": "Theme-aware, responsive legend indicator component."
},
{
"name": "List",
"category": "base",
"purpose": "Present structured responsive linked or status items with icons, descriptions, actions, and selection events."
},
{
"name": "ListGroup",
"category": "base",
"purpose": "Theme-aware, responsive list group component."
},
{
"name": "Marquee",
"category": "base",
"purpose": "Continuously present responsive labels, partners, notices, or capabilities with pause and resume behavior."
},
{
"name": "Progress",
"category": "base",
"purpose": "Theme-aware, responsive progress component."
},
{
"name": "Rating",
"category": "base",
"purpose": "Theme-aware, responsive rating component."
},
{
"name": "Skeleton",
"category": "base",
"purpose": "Theme-aware, responsive skeleton component."
},
{
"name": "Spinner",
"category": "base",
"purpose": "Theme-aware, responsive spinner component."
},
{
"name": "StyledIcon",
"category": "base",
"purpose": "Theme-aware, responsive styled icon component."
},
{
"name": "Timeline",
"category": "base",
"purpose": "Present responsive chronological activity, milestones, or workflow status with rich item metadata."
},
{
"name": "Toast",
"category": "base",
"purpose": "Theme-aware, responsive toast component."
},
{
"name": "TreeView",
"category": "base",
"purpose": "Theme-aware, responsive tree view component."
},
{
"name": "MetricCard",
"category": "data",
"purpose": "Display one operational metric with value, suffix, description, icon, trend, progress, and optional action."
},
{
"name": "MetricGrid",
"category": "data",
"purpose": "Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid."
},
{
"name": "StatsBar",
"category": "data",
"purpose": "Present a compact responsive strip of key facts, counts, performance indicators, or trust signals."
},
{
"name": "Checkbox",
"category": "forms",
"purpose": "Theme-aware, responsive checkbox component."
},
{
"name": "ColorPicker",
"category": "forms",
"purpose": "Theme-aware, responsive color picker component."
},
{
"name": "FileInput",
"category": "forms",
"purpose": "Theme-aware, responsive file input component."
},
{
"name": "Input",
"category": "forms",
"purpose": "Theme-aware, responsive input component."
},
{
"name": "InputGroup",
"category": "forms",
"purpose": "Theme-aware, responsive input group component."
},
{
"name": "Radio",
"category": "forms",
"purpose": "Theme-aware, responsive radio component."
},
{
"name": "RangeSlider",
"category": "forms",
"purpose": "Theme-aware, responsive range slider component."
},
{
"name": "SearchBox",
"category": "forms",
"purpose": "Provide an accessible responsive search field with labels, validation states, sizes, and input or change events."
},
{
"name": "Select",
"category": "forms",
"purpose": "Theme-aware, responsive select component."
},
{
"name": "Switch",
"category": "forms",
"purpose": "Theme-aware, responsive switch component."
},
{
"name": "Textarea",
"category": "forms",
"purpose": "Theme-aware, responsive textarea component."
},
{
"name": "TimePicker",
"category": "forms",
"purpose": "Theme-aware, responsive time picker component."
},
{
"name": "AdvancedDatePicker",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced date picker component."
},
{
"name": "AdvancedRangeSlider",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced range slider component."
},
{
"name": "Chart",
"category": "integrations",
"purpose": "Theme-aware, responsive chart component."
},
{
"name": "Clipboard",
"category": "integrations",
"purpose": "Theme-aware, responsive clipboard component."
},
{
"name": "Collapse",
"category": "base",
"purpose": "Theme-aware, responsive collapse component."
},
{
"name": "ColorPicker",
"category": "forms",
"purpose": "Theme-aware, responsive color picker component."
},
{
"name": "Columns",
"category": "layout",
"purpose": "Create responsive balanced content columns with configurable count, gap, density, and maximum width."
},
{
"name": "ComboBox",
"category": "advanced-forms",
"purpose": "Editable autocomplete combobox with local and remote suggestions."
},
{
"name": "Confetti",
"category": "integrations",
"purpose": "Theme-aware, responsive confetti component."
},
{
"name": "Container",
"category": "layout",
"purpose": "Constrain and align page content with responsive gutters and compact, wide, or full width options."
},
{
"name": "ContextMenu",
"category": "overlays",
"purpose": "Open an accessible keyboard-aware action menu from pointer or keyboard context interactions."
},
{
"name": "CopyMarkup",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive copy markup component."
},
{
"name": "CTASection",
"category": "marketing",
"purpose": "Close a page or major section with conversion-focused copy, actions, and optional supporting visual content."
},
{
"name": "CustomScrollbar",
"category": "layout",
"purpose": "Theme-aware, responsive custom scrollbar component."
},
{
"name": "DataMap",
"category": "integrations",
@@ -322,44 +172,14 @@
"purpose": "Theme-aware, responsive data table component."
},
{
"name": "DragAndDrop",
"category": "integrations",
"purpose": "Theme-aware, responsive drag and drop component."
"name": "DatePicker",
"category": "base",
"purpose": "Theme-aware, responsive date picker component."
},
{
"name": "FileUpload",
"category": "integrations",
"purpose": "Theme-aware, responsive file upload component."
},
{
"name": "Map",
"category": "integrations",
"purpose": "Present responsive location information and markers with map-ready metadata and movement or marker events."
},
{
"name": "ToastNotifications",
"category": "integrations",
"purpose": "Theme-aware, responsive toast notifications component."
},
{
"name": "WysiwygEditor",
"category": "integrations",
"purpose": "Theme-aware, responsive wysiwyg editor component."
},
{
"name": "Columns",
"category": "layout",
"purpose": "Create responsive balanced content columns with configurable count, gap, density, and maximum width."
},
{
"name": "Container",
"category": "layout",
"purpose": "Constrain and align page content with responsive gutters and compact, wide, or full width options."
},
{
"name": "CustomScrollbar",
"category": "layout",
"purpose": "Theme-aware, responsive custom scrollbar component."
"name": "DeviceFrame",
"category": "base",
"purpose": "Theme-aware, responsive device frame component."
},
{
"name": "Divider",
@@ -367,164 +187,9 @@
"purpose": "Separate related horizontal or vertical content with optional labels, sizes, and semantic colors."
},
{
"name": "FeatureGrid",
"category": "layout",
"purpose": "Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid."
},
{
"name": "Footer",
"category": "layout",
"purpose": "Render structured responsive footer navigation, pre and post content, copyright content, links, and public events."
},
{
"name": "Grid",
"category": "layout",
"purpose": "Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width."
},
{
"name": "Image",
"category": "layout",
"purpose": "Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment."
},
{
"name": "Kbd",
"category": "layout",
"purpose": "Theme-aware, responsive kbd component."
},
{
"name": "LayoutSplitter",
"category": "layout",
"purpose": "Theme-aware, responsive layout splitter component."
},
{
"name": "Link",
"category": "layout",
"purpose": "Render an accessible internal or external link with target, relation, size, color, and public focus or click events."
},
{
"name": "PublicPageShell",
"category": "layout",
"purpose": "Provide the outer responsive structure, width, background, slots, overflow, and minimum-height behavior for public pages."
},
{
"name": "Section",
"category": "layout",
"purpose": "Create a responsive themed page section with controlled spacing, width, borders, and surface treatment."
},
{
"name": "SectionHeader",
"category": "layout",
"purpose": "Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy."
},
{
"name": "Typography",
"category": "layout",
"purpose": "Apply consistent readable responsive typography, widths, columns, spacing, and editorial hierarchy."
},
{
"name": "AnnouncementBar",
"category": "marketing",
"purpose": "Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls."
},
{
"name": "CTASection",
"category": "marketing",
"purpose": "Close a page or major section with conversion-focused copy, actions, and optional supporting visual content."
},
{
"name": "FeatureCard",
"category": "marketing",
"purpose": "Present one linked feature or service with media, icon, badge, description, and action."
},
{
"name": "FeatureIconCard",
"category": "marketing",
"purpose": "Present a compact feature or benefit with a styled icon, title, description, badge, and optional link."
},
{
"name": "Hero",
"category": "marketing",
"purpose": "Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel."
},
{
"name": "HeroActions",
"category": "marketing",
"purpose": "Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking."
},
{
"name": "MarketingSectionHeader",
"category": "marketing",
"purpose": "Introduce marketing content with an eyebrow, title, description, and optional linked action."
},
{
"name": "PageHeader",
"category": "marketing",
"purpose": "Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions."
},
{
"name": "SplitHero",
"category": "marketing",
"purpose": "Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel."
},
{
"name": "TextLink",
"category": "marketing",
"purpose": "Render an accessible text action with optional icon, arrow, underline, external state, and semantic styling."
},
{
"name": "BackToTop",
"category": "navigation",
"purpose": "Provide a responsive floating control that returns long pages to the top and can show scroll progress."
},
{
"name": "Breadcrumb",
"category": "navigation",
"purpose": "Show responsive hierarchical navigation with home support, separators, current-page state, sizes, and selection events."
},
{
"name": "MegaMenu",
"category": "navigation",
"purpose": "Theme-aware, responsive mega menu component."
},
{
"name": "Nav",
"category": "navigation",
"purpose": "Theme-aware, responsive nav component."
},
{
"name": "Navbar",
"category": "navigation",
"purpose": "Theme-aware, responsive navbar component."
},
{
"name": "Pagination",
"category": "navigation",
"purpose": "Theme-aware, responsive pagination component."
},
{
"name": "Scrollspy",
"category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component."
},
{
"name": "Sidebar",
"category": "navigation",
"purpose": "Theme-aware, responsive sidebar component."
},
{
"name": "Stepper",
"category": "navigation",
"purpose": "Theme-aware, responsive stepper component."
},
{
"name": "Tabs",
"category": "navigation",
"purpose": "Switch between related responsive content panels with horizontal or vertical orientation and selection events."
},
{
"name": "ContextMenu",
"category": "overlays",
"purpose": "Open an accessible keyboard-aware action menu from pointer or keyboard context interactions."
"name": "DragAndDrop",
"category": "integrations",
"purpose": "Theme-aware, responsive drag and drop component."
},
{
"name": "Drawer",
@@ -536,25 +201,345 @@
"category": "overlays",
"purpose": "Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement."
},
{
"name": "FeatureCard",
"category": "marketing",
"purpose": "Present one linked feature or service with media, icon, badge, description, and action."
},
{
"name": "FeatureGrid",
"category": "layout",
"purpose": "Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid."
},
{
"name": "FeatureIconCard",
"category": "marketing",
"purpose": "Present a compact feature or benefit with a styled icon, title, description, badge, and optional link."
},
{
"name": "FileInput",
"category": "forms",
"purpose": "Theme-aware, responsive file input component."
},
{
"name": "FileUpload",
"category": "integrations",
"purpose": "Theme-aware, responsive file upload component."
},
{
"name": "FileUploadProgress",
"category": "base",
"purpose": "Theme-aware, responsive file upload progress component."
},
{
"name": "Footer",
"category": "layout",
"purpose": "Render structured responsive footer navigation, pre and post content, copyright content, links, and public events."
},
{
"name": "Grid",
"category": "layout",
"purpose": "Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width."
},
{
"name": "Hero",
"category": "marketing",
"purpose": "Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel."
},
{
"name": "HeroActions",
"category": "marketing",
"purpose": "Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking."
},
{
"name": "Image",
"category": "layout",
"purpose": "Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment."
},
{
"name": "Input",
"category": "forms",
"purpose": "Theme-aware, responsive input component."
},
{
"name": "InputGroup",
"category": "forms",
"purpose": "Theme-aware, responsive input group component."
},
{
"name": "InputNumber",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive input number component."
},
{
"name": "Kbd",
"category": "layout",
"purpose": "Theme-aware, responsive kbd component."
},
{
"name": "LayoutSplitter",
"category": "layout",
"purpose": "Theme-aware, responsive layout splitter component."
},
{
"name": "LegendIndicator",
"category": "base",
"purpose": "Theme-aware, responsive legend indicator component."
},
{
"name": "Link",
"category": "layout",
"purpose": "Render an accessible internal or external link with target, relation, size, color, and public focus or click events."
},
{
"name": "List",
"category": "base",
"purpose": "Present structured responsive linked or status items with icons, descriptions, actions, and selection events."
},
{
"name": "ListGroup",
"category": "base",
"purpose": "Theme-aware, responsive list group component."
},
{
"name": "Map",
"category": "integrations",
"purpose": "Present responsive location information and markers with map-ready metadata and movement or marker events."
},
{
"name": "MarketingSectionHeader",
"category": "marketing",
"purpose": "Introduce marketing content with an eyebrow, title, description, and optional linked action."
},
{
"name": "Marquee",
"category": "base",
"purpose": "Continuously present responsive labels, partners, notices, or capabilities with pause and resume behavior."
},
{
"name": "MegaMenu",
"category": "navigation",
"purpose": "Theme-aware, responsive mega menu component."
},
{
"name": "MetricCard",
"category": "data",
"purpose": "Display one operational metric with value, suffix, description, icon, trend, progress, and optional action."
},
{
"name": "MetricGrid",
"category": "data",
"purpose": "Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid."
},
{
"name": "Modal",
"category": "overlays",
"purpose": "Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing."
},
{
"name": "Nav",
"category": "navigation",
"purpose": "Theme-aware, responsive nav component."
},
{
"name": "Navbar",
"category": "navigation",
"purpose": "Theme-aware, responsive navbar component."
},
{
"name": "PageHeader",
"category": "marketing",
"purpose": "Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions."
},
{
"name": "Pagination",
"category": "navigation",
"purpose": "Theme-aware, responsive pagination component."
},
{
"name": "PinInput",
"category": "advanced-forms",
"purpose": "Secure multi-cell PIN and verification-code input with regex and paste support."
},
{
"name": "Popover",
"category": "overlays",
"purpose": "Display anchored supporting content with configurable trigger, placement, responsive sizing, and open or close events."
},
{
"name": "Tooltip",
"category": "overlays",
"purpose": "Show concise accessible contextual help on hover, focus, click, or controlled open state."
"name": "PortalDashboard",
"category": "core",
"purpose": "Reusable portal dashboard component."
},
{
"name": "PreferenceSwitcher",
"category": "core",
"purpose": "Reusable preference switcher component."
},
{
"name": "Progress",
"category": "base",
"purpose": "Theme-aware, responsive progress component."
},
{
"name": "PublicPageShell",
"category": "layout",
"purpose": "Provide the outer responsive structure, width, background, slots, overflow, and minimum-height behavior for public pages."
},
{
"name": "Radio",
"category": "forms",
"purpose": "Theme-aware, responsive radio component."
},
{
"name": "RangeSlider",
"category": "forms",
"purpose": "Theme-aware, responsive range slider component."
},
{
"name": "Rating",
"category": "base",
"purpose": "Theme-aware, responsive rating component."
},
{
"name": "Scrollspy",
"category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component."
},
{
"name": "SearchBox",
"category": "forms",
"purpose": "Provide an accessible responsive search field with labels, validation states, sizes, and input or change events."
},
{
"name": "Section",
"category": "layout",
"purpose": "Create a responsive themed page section with controlled spacing, width, borders, and surface treatment."
},
{
"name": "SectionHeader",
"category": "layout",
"purpose": "Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy."
},
{
"name": "Select",
"category": "forms",
"purpose": "Theme-aware, responsive select component."
},
{
"name": "Sidebar",
"category": "navigation",
"purpose": "Theme-aware, responsive sidebar component."
},
{
"name": "Skeleton",
"category": "base",
"purpose": "Theme-aware, responsive skeleton component."
},
{
"name": "Spinner",
"category": "base",
"purpose": "Theme-aware, responsive spinner component."
},
{
"name": "SplitHero",
"category": "marketing",
"purpose": "Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel."
},
{
"name": "StatsBar",
"category": "data",
"purpose": "Present a compact responsive strip of key facts, counts, performance indicators, or trust signals."
},
{
"name": "Stepper",
"category": "navigation",
"purpose": "Theme-aware, responsive stepper component."
},
{
"name": "StrongPassword",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive strong password component."
},
{
"name": "StyledIcon",
"category": "base",
"purpose": "Theme-aware, responsive styled icon component."
},
{
"name": "Switch",
"category": "forms",
"purpose": "Theme-aware, responsive switch component."
},
{
"name": "Table",
"category": "tables",
"purpose": "Theme-aware, responsive table component."
},
{
"name": "Tabs",
"category": "navigation",
"purpose": "Switch between related responsive content panels with horizontal or vertical orientation and selection events."
},
{
"name": "Textarea",
"category": "forms",
"purpose": "Theme-aware, responsive textarea component."
},
{
"name": "TextLink",
"category": "marketing",
"purpose": "Render an accessible text action with optional icon, arrow, underline, external state, and semantic styling."
},
{
"name": "Timeline",
"category": "base",
"purpose": "Present responsive chronological activity, milestones, or workflow status with rich item metadata."
},
{
"name": "TimePicker",
"category": "forms",
"purpose": "Theme-aware, responsive time picker component."
},
{
"name": "Toast",
"category": "base",
"purpose": "Theme-aware, responsive toast component."
},
{
"name": "ToastNotifications",
"category": "integrations",
"purpose": "Theme-aware, responsive toast notifications component."
},
{
"name": "ToggleCount",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive toggle count component."
},
{
"name": "TogglePassword",
"category": "advanced-forms",
"purpose": "Accessible password field with optional show and hide controls."
},
{
"name": "Tooltip",
"category": "overlays",
"purpose": "Show concise accessible contextual help on hover, focus, click, or controlled open state."
},
{
"name": "TreeView",
"category": "base",
"purpose": "Theme-aware, responsive tree view component."
},
{
"name": "Typography",
"category": "layout",
"purpose": "Apply consistent readable responsive typography, widths, columns, spacing, and editorial hierarchy."
},
{
"name": "WysiwygEditor",
"category": "integrations",
"purpose": "Theme-aware, responsive wysiwyg editor component."
}
]
}
File diff suppressed because it is too large Load Diff
+33 -30
View File
@@ -1,52 +1,55 @@
component Accordion {
props {
size = "default"
color = "primary"
variant = "default"
class = ""
id = "accordion"
items = []
defaultOpen = []
multiple = false
alwaysOpen = false
disabled = false
indicator = "plus"
indicatorPosition = "start"
showIndicator = true
bordered = false
separated = false
flush = false
contentItalic = false
@event change = function
@event open = function
@event close = function
outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
variant: string = "default"
class: string = ""
id: string = "accordion"
items: unknown[] = []
defaultOpen: unknown[] = []
multiple: boolean = false
alwaysOpen: boolean = false
disabled: boolean = false
indicator: string = "plus"
indicatorPosition: string = "start"
showIndicator: boolean = true
bordered: boolean = false
separated: boolean = false
flush: boolean = false
contentItalic: boolean = false
}
state openValues = defaultOpen
functions {
function itemValue(item, index) {
shared function itemValue(item, index) {
return item.value !== undefined && item.value !== ""
? String(item.value)
: String(index)
}
function nestedValue(parent, parentIndex, item, index) {
shared function nestedValue(parent, parentIndex, item, index) {
return itemValue(parent, parentIndex) + "." + itemValue(item, index)
}
function isOpen(value) {
shared function isOpen(value) {
return openValues.includes(value)
}
function allowsMultiple() {
shared function allowsMultiple() {
return multiple || alwaysOpen
}
function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]")
if (!root) {
@@ -64,7 +67,7 @@ component Accordion {
root.dispatchEvent(customEvent)
}
function toggleItem(sourceEvent, value, item, wasOpen) {
client function toggleItem(sourceEvent, value, item, wasOpen) {
if (disabled || item.disabled) {
return
}
+15 -12
View File
@@ -1,17 +1,20 @@
component AdvancedDatePicker {
outputs {
input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
clear(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props {
@event input = function
@event change = function
@event open = function
@event close = function
@event clear = function
size = "default"
color = "primary"
title = "Advanced Date Picker"
description = ""
items = []
variant = "default"
class = ""
size: string = "default"
color: string = "primary"
title: string = "Advanced Date Picker"
description: string = ""
items: unknown[] = []
variant: string = "default"
class: string = ""
}
view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-date-picker wire-next--variant-{variant} {class}">
+14 -11
View File
@@ -1,16 +1,19 @@
component AdvancedRangeSlider {
outputs {
input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
start(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
end(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
@event input = function
@event change = function
@event start = function
@event end = function
size = "default"
color = "primary"
title = "Advanced Range Slider"
description = ""
items = []
variant = "default"
class = ""
size: string = "default"
color: string = "primary"
title: string = "Advanced Range Slider"
description: string = ""
items: unknown[] = []
variant: string = "default"
class: string = ""
}
view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-range-slider wire-next--variant-{variant} {class}">
+85 -82
View File
@@ -1,82 +1,85 @@
component AdvancedSelect {
outputs {
search(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
clear(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
load(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
error(payload: { error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)
}
props {
size = "default"
color = "primary"
label = "Advanced Select"
name = ""
value = ""
values = []
options = []
groups = []
placeholder = "Select an option"
placeholderIcon = ""
searchPlaceholder = "Search options…"
multiple = false
searchable = true
defaultOpen = false
clearable = true
allowEmpty = true
tags = false
disabled = false
required = false
invalid = false
validationMessage = ""
helpText = ""
loading = false
loadingLabel = "Loading options…"
emptyLabel = "No options found"
selectedOptionsLabel = "Selected options"
clearLabel = "Clear selection"
createLabel = "Create"
loadMoreLabel = "Load more"
searchMode = "contains"
searchFields = "label,description"
minSearchLength = 0
searchResultLimit = 0
maxSelections = 0
showCounter = false
counterTemplate = "{selected} selected"
optionTemplate = "default"
selectedTemplate = "default"
closeOnSelect = true
scrollToSelected = true
fixed = false
placement = "bottom"
remote = false
remoteUrl = ""
remoteQueryParam = "q"
remoteDebounce = 250
remoteAutoLoad = true
infinite = false
hasMore = false
page = 1
class = ""
@event search = function
@event select = function
@event change = function
@event clear = function
@event open = function
@event close = function
@event load = function
@event error = function
size: string = "default"
color: string = "primary"
label: string = "Advanced Select"
name: string = ""
value: string = ""
values: unknown[] = []
options: unknown[] = []
groups: unknown[] = []
placeholder: string = "Select an option"
placeholderIcon: string = ""
searchPlaceholder: string = "Search options…"
multiple: boolean = false
searchable: boolean = true
defaultOpen: boolean = false
clearable: boolean = true
allowEmpty: boolean = true
tags: boolean = false
disabled: boolean = false
required: boolean = false
invalid: boolean = false
validationMessage: string = ""
helpText: string = ""
loading: boolean = false
loadingLabel: string = "Loading options…"
emptyLabel: string = "No options found"
selectedOptionsLabel: string = "Selected options"
clearLabel: string = "Clear selection"
createLabel: string = "Create"
loadMoreLabel: string = "Load more"
searchMode: string = "contains"
searchFields: string = "label,description"
minSearchLength: number = 0
searchResultLimit: number = 0
maxSelections: number = 0
showCounter: boolean = false
counterTemplate: string = "{selected} selected"
optionTemplate: string = "default"
selectedTemplate: string = "default"
closeOnSelect: boolean = true
scrollToSelected: boolean = true
fixed: boolean = false
placement: string = "bottom"
remote: boolean = false
remoteUrl: string = ""
remoteQueryParam: string = "q"
remoteDebounce: number = 250
remoteAutoLoad: boolean = true
infinite: boolean = false
hasMore: boolean = false
page: number = 1
class: string = ""
}
state open = defaultOpen
state query = ""
state activeIndex = -1
state query: string = ""
state activeIndex: number = -1
state selectedValue = value
state selectedValues = values
functions {
function allOptions() {
shared function allOptions() {
return [...groups.flatMap((group) => group.options || []), ...options]
}
function searchableText(option) {
shared function searchableText(option) {
return ((option.label || "") + " " + (option.description || "")).toLowerCase()
}
function matches(option) {
shared function matches(option) {
if (!query || query.length < Number(minSearchLength || 0)) {
return true
}
@@ -89,22 +92,22 @@ component AdvancedSelect {
return searchableText(option).includes(query.toLowerCase())
}
function matchingOptions(list) {
shared function matchingOptions(list) {
return list.filter((option) => matches(option))
}
function visibleOptions(list) {
shared function visibleOptions(list) {
if (searchResultLimit > 0) {
return matchingOptions(list).slice(0, Number(searchResultLimit))
}
return matchingOptions(list)
}
function flatVisibleOptions() {
shared function flatVisibleOptions() {
return visibleOptions(allOptions())
}
function isSelected(option) {
shared function isSelected(option) {
return (
multiple
? selectedValues.includes(option.value)
@@ -112,15 +115,15 @@ component AdvancedSelect {
)
}
function selectedOptions() {
shared function selectedOptions() {
return allOptions().filter((option) => isSelected(option))
}
function selectedCount() {
shared function selectedCount() {
return multiple ? selectedValues.length : selectedValue ? 1 : 0
}
function counterText() {
shared function counterText() {
return (
maxSelections
? selectedCount() + " / " + maxSelections + " selected"
@@ -128,7 +131,7 @@ component AdvancedSelect {
)
}
function selectedText() {
shared function selectedText() {
return (
multiple
? selectedValues.join(", ")
@@ -138,11 +141,11 @@ component AdvancedSelect {
)
}
function triggerText() {
shared function triggerText() {
return selectedCount() ? selectedText() : placeholder
}
function canSelect(option) {
shared function canSelect(option) {
if (option.disabled) {
return false
}
@@ -155,7 +158,7 @@ component AdvancedSelect {
return selectedCount() < maxSelections
}
function chooseSingle(option) {
shared function chooseSingle(option) {
if (!canSelect(option)) {
return
}
@@ -166,7 +169,7 @@ component AdvancedSelect {
}
}
function chooseMultiple(option) {
shared function chooseMultiple(option) {
if (!canSelect(option)) {
return
}
@@ -178,7 +181,7 @@ component AdvancedSelect {
query = ""
}
function chooseOption(option) {
shared function chooseOption(option) {
if (multiple) {
chooseMultiple(option)
return
@@ -186,7 +189,7 @@ component AdvancedSelect {
chooseSingle(option)
}
function clearSelection(event) {
client function clearSelection(event) {
event.stopPropagation()
selectedValue = ""
selectedValues = []
@@ -194,7 +197,7 @@ component AdvancedSelect {
open = false
}
function toggle() {
shared function toggle() {
if (disabled) {
return;
};
@@ -202,14 +205,14 @@ component AdvancedSelect {
activeIndex = open && flatVisibleOptions().length ? 0 : -1;
}
function moveActive(direction) {
shared function moveActive(direction) {
if (!flatVisibleOptions().length) {
return
}
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
}
function handleKeydown(event) {
client function handleKeydown(event) {
if (disabled) {
return
}
@@ -243,7 +246,7 @@ component AdvancedSelect {
}
}
function optionIndex(option) {
shared function optionIndex(option) {
return flatVisibleOptions().findIndex((item) => item.value === option.value)
}
}
+25 -23
View File
@@ -1,29 +1,31 @@
component AnnouncementBar {
props {
@event dismiss = function
badge = ""
badgeIcon = ""
message = "Announcement"
description = ""
icon = "icon-[lucide--megaphone]"
actionLabel = ""
actionHref = ""
actionIcon = ""
dismissible = false
dismissLabel = "Dismiss announcement"
sticky = false
compact = false
size = "default"
width = "default"
color = "primary"
variant = "soft"
role = "status"
live = "polite"
class = ""
outputs {
dismiss(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
state dismissed = false
props {
badge: string = ""
badgeIcon: string = ""
message: string = "Announcement"
description: string = ""
icon: string = "icon-[lucide--megaphone]"
actionLabel: string = ""
actionHref: string = ""
actionIcon: string = ""
dismissible: boolean = false
dismissLabel: string = "Dismiss announcement"
sticky: boolean = false
compact: boolean = false
size: string = "default"
width: string = "default"
color: string = "primary"
variant: string = "soft"
role: string = "status"
live: string = "polite"
class: string = ""
}
state dismissed: boolean = false
view {
<aside
+32 -29
View File
@@ -1,27 +1,30 @@
component AuthForm {
outputs {
submit(payload: { event: Event; mode: string; action: string })
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
focus(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
blur(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
@event submit = function
@event change = function
@event input = function
@event focus = function
@event blur = function
size = "default"
color = "primary"
mode = "sign-in"
action = "/api/auth/login"
method = "post"
title = "Sign in"
description = ""
returnTo = ""
schema = ""
showRemember = true
showName = true
submitLabel = "Continue"
class = ""
size: string = "default"
color: string = "primary"
mode: string = "sign-in"
action: string = "/api/auth/login"
method: string = "post"
title: string = "Sign in"
description: string = ""
returnTo: string = ""
schema: string = ""
showRemember: boolean = true
showName: boolean = true
submitLabel: string = "Continue"
class: string = ""
}
functions {
function schemaName() {
shared function schemaName() {
if (schema) return schema
if (mode === "sign-in") return "auth-login"
if (mode === "register") return "auth-register"
@@ -31,12 +34,12 @@ component AuthForm {
return "auth-empty"
}
function submitForm(event) {
$emit("submit", { event: event, mode: mode, action: action })
client function submitForm(event) {
output.submit({ event: event, mode: mode, action: action })
}
function fieldEvent(type, event) {
const detail = event.detail || {}
$emit(type, { event: event, name: detail.name || "", value: detail.value || "" })
client function fieldEvent(type, payload) {
const detail = payload || {}
output[type]({ sourceEvent: detail.sourceEvent, name: detail.name || "", value: detail.value || "" })
}
}
@@ -52,20 +55,20 @@ component AuthForm {
data-schema="{schemaName()}"
data-wrnexus-runtime="auth"
novalidate="true"
@submit="submitForm($event)"
@submit="submitForm(event)"
>
{#if returnTo}<input type="hidden" name="returnTo" value="{returnTo}" />{/if}
{#if mode === "register" && showName}
<Input label="Full name" name="displayName" autocomplete="name" placeholder="Enter your full name" required="true" icon="icon-[lucide--user-round]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
<Input label="Full name" name="displayName" autocomplete="name" placeholder="Enter your full name" required="true" icon="icon-[lucide--user-round]" iconPosition="start" @change="fieldEvent('change', payload)" @input="fieldEvent('input', payload)" @focus="fieldEvent('focus', payload)" @blur="fieldEvent('blur', payload)" />
{/if}
{#if mode === "sign-in" || mode === "register" || mode === "recover"}
<Input label="{mode === 'recover' ? 'Registered email or mobile' : 'Email, mobile, or username'}" name="{mode === 'recover' ? 'identifier' : mode === 'register' ? 'email' : 'identifier'}" type="{mode === 'register' ? 'email' : 'text'}" autocomplete="{mode === 'register' ? 'email' : 'username'}" placeholder="{mode === 'recover' ? 'Enter your registered identity' : 'Enter your identity'}" required="true" icon="icon-[lucide--at-sign]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
<Input label="{mode === 'recover' ? 'Registered email or mobile' : 'Email, mobile, or username'}" name="{mode === 'recover' ? 'identifier' : mode === 'register' ? 'email' : 'identifier'}" type="{mode === 'register' ? 'email' : 'text'}" autocomplete="{mode === 'register' ? 'email' : 'username'}" placeholder="{mode === 'recover' ? 'Enter your registered identity' : 'Enter your identity'}" required="true" icon="icon-[lucide--at-sign]" iconPosition="start" @change="fieldEvent('change', payload)" @input="fieldEvent('input', payload)" @focus="fieldEvent('focus', payload)" @blur="fieldEvent('blur', payload)" />
{/if}
{#if mode === "sign-in" || mode === "register" || mode === "reset"}
<Input label="{mode === 'reset' ? 'New password' : 'Password'}" name="password" type="password" autocomplete="{mode === 'sign-in' ? 'current-password' : 'new-password'}" placeholder="Enter your password" required="true" icon="icon-[lucide--lock-keyhole]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
<Input label="{mode === 'reset' ? 'New password' : 'Password'}" name="password" type="password" autocomplete="{mode === 'sign-in' ? 'current-password' : 'new-password'}" placeholder="Enter your password" required="true" icon="icon-[lucide--lock-keyhole]" iconPosition="start" @change="fieldEvent('change', payload)" @input="fieldEvent('input', payload)" @focus="fieldEvent('focus', payload)" @blur="fieldEvent('blur', payload)" />
{/if}
{#if mode === "register" || mode === "reset"}
<Input label="Confirm password" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Enter the password again" required="true" icon="icon-[lucide--shield-check]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
<Input label="Confirm password" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Enter the password again" required="true" icon="icon-[lucide--shield-check]" iconPosition="start" @change="fieldEvent('change', payload)" @input="fieldEvent('input', payload)" @focus="fieldEvent('focus', payload)" @blur="fieldEvent('blur', payload)" />
{/if}
{#if mode === "mfa"}
<PinInput label="Verification code" name="code" length={6} inputMode="numeric" />
+8 -8
View File
@@ -1,13 +1,13 @@
component AuthSplitLayout {
props {
size = "default"
color = "primary"
eyebrow = "Secure identity"
title = "Welcome back"
description = ""
brand = "Police Management System"
features = []
class = ""
size: string = "default"
color: string = "primary"
eyebrow: string = "Secure identity"
title: string = "Welcome back"
description: string = ""
brand: string = "Police Management System"
features: unknown[] = []
class: string = ""
}
view {
+22 -19
View File
@@ -1,33 +1,36 @@
component AvatarGroup {
props {
items = []
size = "md"
color = "primary"
variant = "solid"
shape = "circle"
layout = "stack"
maxVisible = 4
columns = 3
borderColor = ""
showTooltips = true
overflowLabel = "Show remaining members"
class = ""
@event overflow = function
outputs {
overflow(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
}
state overflowOpen = false
props {
items: unknown[] = []
size: string = "md"
color: string = "primary"
variant: string = "solid"
shape: string = "circle"
layout: string = "stack"
maxVisible: number = 4
columns: number = 3
borderColor: string = ""
showTooltips: boolean = true
overflowLabel: string = "Show remaining members"
class: string = ""
}
state overflowOpen: boolean = false
functions {
function visibleMembers() {
shared function visibleMembers() {
return items.slice(0, Number(maxVisible))
}
function hiddenMembers() {
shared function hiddenMembers() {
return items.slice(Number(maxVisible))
}
function toggleOverflow(sourceEvent, root, customEvent) {
client function toggleOverflow(sourceEvent, root, customEvent) {
overflowOpen = !overflowOpen
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]")
+17 -17
View File
@@ -1,24 +1,24 @@
component BackToTop {
props {
threshold = 500
label = "Back to top"
ariaLabel = "Scroll back to top"
icon = "icon-[lucide--arrow-up]"
position = "right"
offset = "md"
behavior = "smooth"
showProgress = false
showLabel = false
alwaysVisible = false
size = "default"
color = "primary"
variant = "solid"
shape = "round"
class = ""
threshold: number = 500
label: string = "Back to top"
ariaLabel: string = "Scroll back to top"
icon: string = "icon-[lucide--arrow-up]"
position: string = "right"
offset: string = "md"
behavior: string = "smooth"
showProgress: boolean = false
showLabel: boolean = false
alwaysVisible: boolean = false
size: string = "default"
color: string = "primary"
variant: string = "solid"
shape: string = "round"
class: string = ""
}
state visible = false
state progress = 0
state visible: boolean = false
state progress: number = 0
view {
<button
+13 -13
View File
@@ -1,18 +1,18 @@
component Blockquote {
props {
quote = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."
citation = ""
citationTitle = ""
citationUrl = ""
avatarSrc = ""
avatarAlt = ""
size = "md"
color = "primary"
align = "left"
variant = "default"
quoteMark = true
italic = true
class = ""
quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."
citation: string = ""
citationTitle: string = ""
citationUrl: string = ""
avatarSrc: string = ""
avatarAlt: string = ""
size: string = "md"
color: string = "primary"
align: string = "left"
variant: string = "default"
quoteMark: boolean = true
italic: boolean = true
class: string = ""
}
view {
+16 -14
View File
@@ -1,19 +1,21 @@
component Breadcrumb {
props {
@event select = function
outputs {
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
label = "Breadcrumb"
items = []
active = ""
separator = "chevron"
showHome = false
homeLabel = "Home"
homeHref = "/"
homeIcon = "icon-[lucide--house]"
size = "default"
color = "primary"
variant = "minimal"
class = ""
props {
label: string = "Breadcrumb"
items: unknown[] = []
active: string = ""
separator: string = "chevron"
showHome: boolean = false
homeLabel: string = "Home"
homeHref: string = "/"
homeIcon: string = "icon-[lucide--house]"
size: string = "default"
color: string = "primary"
variant: string = "minimal"
class: string = ""
}
view {
+23 -20
View File
@@ -1,35 +1,38 @@
component ButtonGroup {
outputs {
click(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
select(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
change(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
}
props {
@event click = function
@event select = function
@event change = function
items = []
value = ""
size = "md"
color = "primary"
variant = "default"
orientation = "horizontal"
responsive = false
attached = true
selectable = false
toolbar = false
disabled = false
ariaLabel = "Button group"
class = ""
items: unknown[] = []
value: string = ""
size: string = "md"
color: string = "primary"
variant: string = "default"
orientation: string = "horizontal"
responsive: boolean = false
attached: boolean = true
selectable: boolean = false
toolbar: boolean = false
disabled: boolean = false
ariaLabel: string = "Button group"
class: string = ""
}
state selectedValue = value
functions {
function itemValue(item, index) {
shared function itemValue(item, index) {
return item.value || item.label || String(index)
}
function selectItem(item, index) {
client function selectItem(item, index) {
previousValue = selectedValue
nextValue = itemValue(item, index)
$emit("select", {
output.select({
value: nextValue,
previousValue: previousValue,
item: item,
@@ -38,7 +41,7 @@ component ButtonGroup {
if (selectable && previousValue !== nextValue) {
selectedValue = nextValue
$emit("change", {
output.change({
value: nextValue,
previousValue: previousValue,
item: item,
+25 -25
View File
@@ -1,30 +1,30 @@
component CTASection {
props {
eyebrow = ""
title = "Ready to get started?"
description = ""
icon = ""
align = "center"
size = "default"
color = "primary"
variant = "solid"
primaryLabel = "Get started"
primaryHref = "#"
primaryIcon = ""
secondaryLabel = ""
secondaryHref = ""
secondaryIcon = ""
backgroundImage = ""
visualImage = ""
visualAlt = ""
visualIcon = ""
visualTitle = ""
visualDescription = ""
visualItems = []
visualPosition = "right"
maxWidth = "xl"
fullBleed = false
class = ""
eyebrow: string = ""
title: string = "Ready to get started?"
description: string = ""
icon: string = ""
align: string = "center"
size: string = "default"
color: string = "primary"
variant: string = "solid"
primaryLabel: string = "Get started"
primaryHref: string = "#"
primaryIcon: string = ""
secondaryLabel: string = ""
secondaryHref: string = ""
secondaryIcon: string = ""
backgroundImage: string = ""
visualImage: string = ""
visualAlt: string = ""
visualIcon: string = ""
visualTitle: string = ""
visualDescription: string = ""
visualItems: unknown[] = []
visualPosition: string = "right"
maxWidth: string = "xl"
fullBleed: boolean = false
class: string = ""
}
view {

Some files were not shown because too many files have changed in this diff Show More