release: WRNexusJS 0.6.0
This commit is contained in:
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+609
-4
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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};
|
||||
`;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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,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}}
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type DevToolbarSeverity = "error" | "warning" | "info" | "suggestion";
|
||||
|
||||
export type DevToolbarCategory =
|
||||
| "runtime"
|
||||
| "stores"
|
||||
| "compiler"
|
||||
| "server"
|
||||
| "routing"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+423
-438
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4567
-1668
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user