feat: close application architecture gaps
This commit is contained in:
@@ -292,7 +292,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.55",
|
"version": "0.8.57",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.17",
|
"version": "0.8.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.29",
|
"version": "0.8.31",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.48",
|
"version": "0.8.50",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -499,6 +499,10 @@
|
|||||||
"wrnexus-mcp": "src/stdio.ts",
|
"wrnexus-mcp": "src/stdio.ts",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/metering": {
|
||||||
|
"name": "@wrnexus/metering",
|
||||||
|
"version": "0.8.2",
|
||||||
|
},
|
||||||
"packages/mobile": {
|
"packages/mobile": {
|
||||||
"name": "@wrnexus/mobile",
|
"name": "@wrnexus/mobile",
|
||||||
"version": "0.8.8",
|
"version": "0.8.8",
|
||||||
@@ -666,7 +670,7 @@
|
|||||||
},
|
},
|
||||||
"packages/typecheck": {
|
"packages/typecheck": {
|
||||||
"name": "@wrnexus/typecheck",
|
"name": "@wrnexus/typecheck",
|
||||||
"version": "0.8.11",
|
"version": "0.8.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -1052,6 +1056,8 @@
|
|||||||
|
|
||||||
"@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"],
|
"@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"],
|
||||||
|
|
||||||
|
"@wrnexus/metering": ["@wrnexus/metering@workspace:packages/metering"],
|
||||||
|
|
||||||
"@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"],
|
"@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"],
|
||||||
|
|
||||||
"@wrnexus/native": ["@wrnexus/native@workspace:packages/native"],
|
"@wrnexus/native": ["@wrnexus/native@workspace:packages/native"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.55",
|
"version": "0.8.57",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { runBuild } from "./build.ts";
|
import { runBuild } from "./build.ts";
|
||||||
import { runTypecheck } from "./types.ts";
|
import { runTypecheck } from "./types.ts";
|
||||||
|
import { assertReachability } from "./reachability.ts";
|
||||||
|
|
||||||
async function runScript(root: string, name: string): Promise<void> {
|
async function runScript(root: string, name: string): Promise<void> {
|
||||||
const child = Bun.spawn(["bun", "run", name], {
|
const child = Bun.spawn(["bun", "run", name], {
|
||||||
@@ -20,6 +21,7 @@ export async function runCheck(appRoot: string): Promise<void> {
|
|||||||
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
|
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
|
||||||
scripts?: Record<string, string>;
|
scripts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
assertReachability(root);
|
||||||
await runBuild(root);
|
await runBuild(root);
|
||||||
if (manifest.scripts?.typecheck) await runScript(root, "typecheck");
|
if (manifest.scripts?.typecheck) await runScript(root, "typecheck");
|
||||||
else if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed");
|
else if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed");
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||||
|
|
||||||
|
export interface ReachabilityIssue {
|
||||||
|
code: "WRN-REACH-MIDDLEWARE" | "WRN-REACH-QUEUE" | "WRN-REACH-WORKER";
|
||||||
|
file: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFiles(directory: string): string[] {
|
||||||
|
if (!existsSync(directory)) return [];
|
||||||
|
const output: string[] = [];
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) output.push(...sourceFiles(path));
|
||||||
|
else if ([".ts", ".js", ".mts", ".mjs"].includes(extname(entry.name))) output.push(path);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static registration audit for convention-discovered application modules. */
|
||||||
|
export function checkReachability(appRoot: string): ReachabilityIssue[] {
|
||||||
|
const root = resolve(appRoot);
|
||||||
|
const app = join(root, "app");
|
||||||
|
const all = sourceFiles(app).map((file) => ({ file, source: readFileSync(file, "utf8") }));
|
||||||
|
const issues: ReachabilityIssue[] = [];
|
||||||
|
|
||||||
|
for (const entry of all) {
|
||||||
|
const path = relative(root, entry.file).replace(/\\/g, "/");
|
||||||
|
if (path.startsWith("app/middleware/") && !/\bexport\s+default\b/.test(entry.source)) {
|
||||||
|
issues.push({
|
||||||
|
code: "WRN-REACH-MIDDLEWARE",
|
||||||
|
file: path,
|
||||||
|
message: "Middleware discovery requires a default export.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path.startsWith("app/queues/") &&
|
||||||
|
/\bdefineQueue\s*\(/.test(entry.source) &&
|
||||||
|
!/\bexport\s+default\b/.test(entry.source)
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
code: "WRN-REACH-QUEUE",
|
||||||
|
file: path,
|
||||||
|
message: "Queue discovery requires the defined queue to be exported as default.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\bdefineWorker\s*(?:<[^>]+>)?\s*\(/.test(entry.source) &&
|
||||||
|
!/\brunWorker\s*\(/.test(entry.source)
|
||||||
|
) {
|
||||||
|
const stem = entry.file.replace(/\.[^.]+$/, "");
|
||||||
|
const imported = all.some(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.file !== entry.file &&
|
||||||
|
candidate.source
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.includes(relative(dirname(candidate.file), stem).replace(/\\/g, "/")),
|
||||||
|
);
|
||||||
|
if (!imported) {
|
||||||
|
issues.push({
|
||||||
|
code: "WRN-REACH-WORKER",
|
||||||
|
file: path,
|
||||||
|
message: "Worker is defined but no application module imports it or calls runWorker().",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertReachability(appRoot: string): void {
|
||||||
|
const issues = checkReachability(appRoot);
|
||||||
|
if (!issues.length) return;
|
||||||
|
throw new Error(
|
||||||
|
`WRN-REACHABILITY: unreachable application exports\n${issues
|
||||||
|
.map((issue) => `- ${issue.code} ${issue.file}: ${issue.message}`)
|
||||||
|
.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { checkReachability } from "../src/reachability.ts";
|
||||||
|
|
||||||
|
test("reports convention modules and workers that cannot be reached", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrn-reach-"));
|
||||||
|
mkdirSync(join(root, "app", "middleware"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app", "queues"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "app", "middleware", "audit.ts"), "export const audit = () => {};\n");
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app", "queues", "mail.ts"),
|
||||||
|
"export const mail = defineQueue({}); export const worker = defineWorker({});\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
checkReachability(root)
|
||||||
|
.map((issue) => issue.code)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["WRN-REACH-MIDDLEWARE", "WRN-REACH-QUEUE", "WRN-REACH-WORKER"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts discovered middleware and queues", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrn-reach-"));
|
||||||
|
mkdirSync(join(root, "app", "middleware"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app", "queues"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "app", "middleware", "audit.ts"), "export default () => {};\n");
|
||||||
|
writeFileSync(join(root, "app", "queues", "mail.ts"), "export default defineQueue({});\n");
|
||||||
|
expect(checkReachability(root)).toEqual([]);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.17",
|
"version": "0.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -283,6 +283,32 @@ function componentEventAttribute(name: string): string {
|
|||||||
return `data-wrn-out-${name}`;
|
return `data-wrn-out-${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expand bind:value/bind:checked into a reactive prop plus assignment handler. */
|
||||||
|
function expandBindings(attrs: Attr[]): Attr[] {
|
||||||
|
const output: Attr[] = [];
|
||||||
|
for (const attr of attrs) {
|
||||||
|
if (attr.event || !attr.name.startsWith("bind:")) {
|
||||||
|
output.push(attr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const target = attr.name.slice("bind:".length);
|
||||||
|
if (target !== "value" && target !== "checked") {
|
||||||
|
throw new Error(`Unknown .wrn binding '${attr.name}'. Use bind:value or bind:checked.`);
|
||||||
|
}
|
||||||
|
const expression = wholeAttributeExpression(attr.value) ?? attr.value.trim();
|
||||||
|
if (!/^[A-Za-z_$][\w$]*$/.test(expression)) {
|
||||||
|
throw new Error(`${attr.name} requires a writable state name, received '${expression}'.`);
|
||||||
|
}
|
||||||
|
output.push({ name: target, value: `{${expression}}`, event: false });
|
||||||
|
output.push({
|
||||||
|
name: target === "checked" ? "change" : "input",
|
||||||
|
value: `${expression} = payload.${target}`,
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
|
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
|
||||||
let found = false;
|
let found = false;
|
||||||
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||||
@@ -305,6 +331,7 @@ function renderAttrs(
|
|||||||
reactive: PageReactive | null = null,
|
reactive: PageReactive | null = null,
|
||||||
dynamicExpressions?: string[],
|
dynamicExpressions?: string[],
|
||||||
): string {
|
): string {
|
||||||
|
attrs = expandBindings(attrs);
|
||||||
let bindIndex = 0;
|
let bindIndex = 0;
|
||||||
const rendered = attrs
|
const rendered = attrs
|
||||||
.map((attr) => {
|
.map((attr) => {
|
||||||
@@ -463,7 +490,7 @@ function renderLoopBody(node: ViewNode, locals: string[] = []): string {
|
|||||||
|
|
||||||
const componentTag = isComponentTag(node.tag);
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = expandBindings(node.attrs)
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => {
|
.map((attr) => {
|
||||||
const name = attr.event
|
const name = attr.event
|
||||||
@@ -805,7 +832,7 @@ function renderPageComponentInvocation(
|
|||||||
const island = islandMarkerFor(node);
|
const island = islandMarkerFor(node);
|
||||||
if (island) return island;
|
if (island) return island;
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = expandBindings(node.attrs)
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||||
.join("");
|
.join("");
|
||||||
@@ -827,7 +854,7 @@ function renderNestedComponentInvocation(
|
|||||||
|
|
||||||
let bindIndex = 0;
|
let bindIndex = 0;
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = expandBindings(node.attrs)
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => {
|
.map((attr) => {
|
||||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
|
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
|
||||||
@@ -2251,7 +2278,7 @@ function renderClientControlTemplate(nodes: ViewNode[]): string {
|
|||||||
|
|
||||||
const componentTag = isComponentTag(node.tag);
|
const componentTag = isComponentTag(node.tag);
|
||||||
let bindIndex = 0;
|
let bindIndex = 0;
|
||||||
const attrs = node.attrs
|
const attrs = expandBindings(node.attrs)
|
||||||
.map((attribute) => {
|
.map((attribute) => {
|
||||||
const name = attribute.event
|
const name = attribute.event
|
||||||
? componentTag
|
? componentTag
|
||||||
@@ -2293,24 +2320,27 @@ function encodeClientControl(value: unknown): string {
|
|||||||
|
|
||||||
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
|
const clientBranches: string[] = [];
|
||||||
|
|
||||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||||
const branch = node.branches[index]!;
|
const branch = node.branches[index]!;
|
||||||
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
|
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
const bodyExpression = "`" + body + "`";
|
const bodyExpression = "`" + body + "`";
|
||||||
|
|
||||||
|
clientBranches.unshift(
|
||||||
|
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: ${bodyExpression} }`,
|
||||||
|
);
|
||||||
|
|
||||||
expression =
|
expression =
|
||||||
branch.cond === null
|
branch.cond === null
|
||||||
? bodyExpression
|
? bodyExpression
|
||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const definition = encodeClientControl(
|
// Render all branches on the server into an inert, encoded definition.
|
||||||
node.branches.map((branch) => ({
|
// A branch first selected in the browser therefore contains the real nested
|
||||||
cond: branch.cond,
|
// component HTML and scope metadata, not a data-component placeholder.
|
||||||
body: renderClientControlTemplate(branch.body),
|
const definition = `\${__wrnexusEncodeControl([${clientBranches.join(",")}])}`;
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
@@ -2489,7 +2519,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
|||||||
(attribute) => attribute.name === "data-component",
|
(attribute) => attribute.name === "data-component",
|
||||||
);
|
);
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = expandBindings(node.attrs)
|
||||||
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name);
|
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name);
|
||||||
@@ -3036,6 +3066,9 @@ function __wrnRaw(v: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsScope) {
|
if (needsScope) {
|
||||||
|
out.push(`function __wrnexusEncodeControl(value: unknown): string {
|
||||||
|
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}`);
|
||||||
out.push(`function __wrnexusSerializeScopeValue(value: unknown): string {
|
out.push(`function __wrnexusSerializeScopeValue(value: unknown): string {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return "undefined";
|
return "undefined";
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ function __wrnRaw(v: unknown): string {
|
|||||||
return String(v == null ? "" : v);
|
return String(v == null ? "" : v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEncodeControl(value: unknown): string {
|
||||||
|
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusSerializeScopeValue(value: unknown): string {
|
function __wrnexusSerializeScopeValue(value: unknown): string {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return "undefined";
|
return "undefined";
|
||||||
|
|||||||
@@ -1070,6 +1070,25 @@ component Banner {
|
|||||||
expect(output).toContain("visible");
|
expect(output).toContain("visible");
|
||||||
expect(output).toContain("Visible");
|
expect(output).toContain("Visible");
|
||||||
expect(output).toContain("Hidden");
|
expect(output).toContain("Hidden");
|
||||||
|
expect(output).toContain("__wrnexusEncodeControl");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bind directives compile to reactive values and assignment handlers", () => {
|
||||||
|
const output = generate(
|
||||||
|
parse(`page FormPage {
|
||||||
|
state name = ""
|
||||||
|
state active = false
|
||||||
|
view {
|
||||||
|
<Input bind:value={name} />
|
||||||
|
<input bind:value={name} />
|
||||||
|
<Switch bind:checked={active} />
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
expect(output).not.toContain("bind:value");
|
||||||
|
expect(output).not.toContain("bind:checked");
|
||||||
|
expect(output).toContain("name = payload.value");
|
||||||
|
expect(output).toContain("active = payload.checked");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("if and each blocks emit browser control metadata while preserving SSR", () => {
|
test("if and each blocks emit browser control metadata while preserving SSR", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.29",
|
"version": "0.8.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1173,6 +1173,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
* lacks one of these does not break the rest.
|
* lacks one of these does not break the rest.
|
||||||
*/
|
*/
|
||||||
if (name === "toast") return toastApi;
|
if (name === "toast") return toastApi;
|
||||||
|
if (name === "useFetch") return useFetch;
|
||||||
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
||||||
return window[name].bind(window);
|
return window[name].bind(window);
|
||||||
}
|
}
|
||||||
@@ -4350,6 +4351,29 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public client transport for dynamic API paths.
|
||||||
|
*
|
||||||
|
* The apis block remains the typed, declarative choice for fixed routes. This
|
||||||
|
* helper covers paths assembled at runtime without exposing a private
|
||||||
|
* framework global to application code.
|
||||||
|
*/
|
||||||
|
function useFetch(path, methodOrOptions, input) {
|
||||||
|
var method = "GET";
|
||||||
|
var values = input;
|
||||||
|
if (typeof methodOrOptions === "string") {
|
||||||
|
method = methodOrOptions;
|
||||||
|
} else if (methodOrOptions && typeof methodOrOptions === "object") {
|
||||||
|
method = methodOrOptions.method || "GET";
|
||||||
|
values = method === "GET" || method === "HEAD"
|
||||||
|
? (methodOrOptions.query || methodOrOptions.params)
|
||||||
|
: (methodOrOptions.body === undefined ? methodOrOptions.data : methodOrOptions.body);
|
||||||
|
}
|
||||||
|
return wrnexusCallApi(path, method, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.useFetch = useFetch;
|
||||||
|
// Compatibility for applications compiled before the public helper landed.
|
||||||
window.__wrnexusCallApi = wrnexusCallApi;
|
window.__wrnexusCallApi = wrnexusCallApi;
|
||||||
|
|
||||||
function dispatchComponentEvent(root, name, detail) {
|
function dispatchComponentEvent(root, name, detail) {
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ test("POST sends a JSON body", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("useFetch is the public dynamic-path transport", async () => {
|
||||||
|
const { calls, win } = harness({ status: 200, payload: { ok: true } });
|
||||||
|
const useFetch = (
|
||||||
|
win as unknown as {
|
||||||
|
useFetch: (path: string, options: { method: string; body: unknown }) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).useFetch;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useFetch("/api/users/42/test", { method: "POST", body: { dryRun: true } }),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(calls[0]!.url).toBe("/api/users/42/test");
|
||||||
|
expect(calls[0]!.init.body).toBe(JSON.stringify({ dryRun: true }));
|
||||||
|
});
|
||||||
|
|
||||||
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||||
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||||
win.document.cookie = "wrn-csrf=token-123";
|
win.document.cookie = "wrn-csrf=token-123";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.48",
|
"version": "0.8.50",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
/**
|
/**
|
||||||
* Shared request runtime used by BOTH the dev server and the production server.
|
* Shared request runtime used by BOTH the dev server and the production server.
|
||||||
*
|
*
|
||||||
@@ -1667,6 +1668,34 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
language?: string,
|
language?: string,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// Control definitions are inert base64 JSON. Resolve component mounts in
|
||||||
|
// their branch templates too, otherwise a component first selected by
|
||||||
|
// client state would materialize only an empty data-component placeholder.
|
||||||
|
const controlPattern = /data-wrn-(?:if|each)="([A-Za-z0-9+/=]+)"/g;
|
||||||
|
const replacements: Array<{ from: string; to: string }> = [];
|
||||||
|
for (const match of body.matchAll(controlPattern)) {
|
||||||
|
try {
|
||||||
|
const definition = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as
|
||||||
|
Array<{ body?: string }> | { body?: string; empty?: string };
|
||||||
|
const entries = Array.isArray(definition) ? definition : [definition];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry.body === "string" && entry.body.includes("data-component="))
|
||||||
|
entry.body = await renderComponents(entry.body, translate, language, depth + 1);
|
||||||
|
if (
|
||||||
|
"empty" in entry &&
|
||||||
|
typeof entry.empty === "string" &&
|
||||||
|
entry.empty.includes("data-component=")
|
||||||
|
)
|
||||||
|
entry.empty = await renderComponents(entry.empty, translate, language, depth + 1);
|
||||||
|
}
|
||||||
|
const encoded = Buffer.from(JSON.stringify(definition), "utf8").toString("base64");
|
||||||
|
replacements.push({ from: match[0], to: match[0].replace(match[1]!, encoded) });
|
||||||
|
} catch {
|
||||||
|
// The client reports malformed control metadata with source context.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const replacement of replacements) body = body.replace(replacement.from, replacement.to);
|
||||||
|
|
||||||
if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) {
|
if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@wrnexus/metering",
|
||||||
|
"version": "0.8.2",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
export interface EntitlementPlan {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
features: readonly string[];
|
||||||
|
allowance?: number;
|
||||||
|
price?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntitlementsOptions<TPlan extends EntitlementPlan> {
|
||||||
|
plans: () => Promise<readonly TPlan[]> | readonly TPlan[];
|
||||||
|
subscriptionFor: (subjectId: string) => Promise<string | null | undefined>;
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve plans, features, and allowances from one server-owned catalog. */
|
||||||
|
export function defineEntitlements<TPlan extends EntitlementPlan>(
|
||||||
|
options: EntitlementsOptions<TPlan>,
|
||||||
|
) {
|
||||||
|
const all = async () => [...(await options.plans())];
|
||||||
|
const planFor = async (subjectId: string): Promise<TPlan> => {
|
||||||
|
const plans = await all();
|
||||||
|
const requested = await options.subscriptionFor(subjectId);
|
||||||
|
const plan =
|
||||||
|
plans.find((entry) => entry.code === requested) ??
|
||||||
|
plans.find((entry) => entry.code === options.fallback);
|
||||||
|
if (!plan) throw new Error(`WRN-ENTITLEMENTS: fallback plan '${options.fallback}' is missing`);
|
||||||
|
return plan;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
plans: all,
|
||||||
|
planFor,
|
||||||
|
async enabled(subjectId: string, feature: string) {
|
||||||
|
return (await planFor(subjectId)).features.includes(feature);
|
||||||
|
},
|
||||||
|
async allowance(subjectId: string) {
|
||||||
|
return Number((await planFor(subjectId)).allowance ?? 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterPack {
|
||||||
|
code: string;
|
||||||
|
units: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prototype-safe, immutable pack lookup; clients select a code, never an amount. */
|
||||||
|
export function definePacks<TPack extends MeterPack>(entries: readonly TPack[]) {
|
||||||
|
const packs = new Map<string, Readonly<TPack>>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.code.trim()) throw new TypeError("meter pack code cannot be empty");
|
||||||
|
if (!Number.isSafeInteger(entry.units) || entry.units < 1)
|
||||||
|
throw new RangeError(`meter pack '${entry.code}' units must be a positive safe integer`);
|
||||||
|
if (packs.has(entry.code)) throw new TypeError(`duplicate meter pack '${entry.code}'`);
|
||||||
|
packs.set(entry.code, Object.freeze({ ...entry }));
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
get: (code: string) => packs.get(code),
|
||||||
|
has: (code: string) => packs.has(code),
|
||||||
|
list: () => [...packs.values()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeterResult = { ok: true } | { ok: false; reason: string; fault?: true };
|
||||||
|
export type MeterKind = "grant" | "purchase" | "refund" | "adjust";
|
||||||
|
|
||||||
|
export interface MeterStore {
|
||||||
|
balance(subjectId: string): Promise<number>;
|
||||||
|
reserve(subjectId: string, units: number, reason: string, reference: string): Promise<boolean>;
|
||||||
|
write(
|
||||||
|
subjectId: string,
|
||||||
|
units: number,
|
||||||
|
kind: MeterKind,
|
||||||
|
reason: string,
|
||||||
|
reference: string,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterOptions {
|
||||||
|
store: MeterStore;
|
||||||
|
label?: string;
|
||||||
|
onFault?: (error: unknown, operation: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-throwing metering facade with validated units and explicit storage faults. */
|
||||||
|
export function defineMeter(options: MeterOptions) {
|
||||||
|
const label = options.label ?? "units";
|
||||||
|
const valid = (units: number): MeterResult | null =>
|
||||||
|
Number.isSafeInteger(units) && units > 0
|
||||||
|
? null
|
||||||
|
: { ok: false, reason: `${label} must be a positive whole number` };
|
||||||
|
const fault = (error: unknown, operation: string): MeterResult => {
|
||||||
|
options.onFault?.(error, operation);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
fault: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const add = async (
|
||||||
|
kind: MeterKind,
|
||||||
|
subjectId: string,
|
||||||
|
units: number,
|
||||||
|
reason: string,
|
||||||
|
reference = "",
|
||||||
|
): Promise<MeterResult> => {
|
||||||
|
const invalid = valid(units);
|
||||||
|
if (invalid) return invalid;
|
||||||
|
try {
|
||||||
|
await options.store.write(subjectId, units, kind, reason, reference);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return fault(error, kind);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
balance: options.store.balance,
|
||||||
|
async reserve(
|
||||||
|
subjectId: string,
|
||||||
|
units: number,
|
||||||
|
reason: string,
|
||||||
|
reference = "",
|
||||||
|
): Promise<MeterResult> {
|
||||||
|
const invalid = valid(units);
|
||||||
|
if (invalid) return invalid;
|
||||||
|
try {
|
||||||
|
return (await options.store.reserve(subjectId, units, reason, reference))
|
||||||
|
? { ok: true }
|
||||||
|
: { ok: false, reason: "insufficient balance" };
|
||||||
|
} catch (error) {
|
||||||
|
return fault(error, "reserve");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grant: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||||
|
add("grant", subjectId, units, reason, reference),
|
||||||
|
purchase: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||||
|
add("purchase", subjectId, units, reason, reference),
|
||||||
|
refund: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||||
|
add("refund", subjectId, units, reason, reference),
|
||||||
|
adjust: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||||
|
add("adjust", subjectId, units, reason, reference),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { defineEntitlements, defineMeter, definePacks } from "../src/index.ts";
|
||||||
|
|
||||||
|
test("resolves fallback entitlements and features", async () => {
|
||||||
|
const entitlements = defineEntitlements({
|
||||||
|
plans: () => [
|
||||||
|
{ code: "free", name: "Free", features: [], allowance: 10 },
|
||||||
|
{ code: "pro", name: "Pro", features: ["schedule"], allowance: 100 },
|
||||||
|
],
|
||||||
|
subscriptionFor: async () => "pro",
|
||||||
|
fallback: "free",
|
||||||
|
});
|
||||||
|
expect(await entitlements.enabled("u1", "schedule")).toBe(true);
|
||||||
|
expect(await entitlements.allowance("u1")).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("packs are immutable and selected only by code", () => {
|
||||||
|
const packs = definePacks([{ code: "small", units: 100, label: "Small" }]);
|
||||||
|
expect(packs.get("constructor")).toBeUndefined();
|
||||||
|
expect(packs.get("small")?.units).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("meter distinguishes refusals from storage faults", async () => {
|
||||||
|
const meter = defineMeter({
|
||||||
|
store: {
|
||||||
|
balance: async () => 5,
|
||||||
|
reserve: async (_subject, units) => units <= 5,
|
||||||
|
write: async () => {
|
||||||
|
throw new Error("offline");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await meter.reserve("u1", 6, "send")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "insufficient balance",
|
||||||
|
});
|
||||||
|
expect(await meter.grant("u1", 5, "plan")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "offline",
|
||||||
|
fault: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/typecheck",
|
"name": "@wrnexus/typecheck",
|
||||||
"version": "0.8.11",
|
"version": "0.8.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -307,6 +307,9 @@ export function virtualTypeScriptModule(
|
|||||||
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
|
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
|
||||||
append(`declare const props: Readonly<${ast.name}Props>;`);
|
append(`declare const props: Readonly<${ast.name}Props>;`);
|
||||||
append("declare const refs: Record<string, Element | null>;");
|
append("declare const refs: Record<string, Element | null>;");
|
||||||
|
append(
|
||||||
|
"declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;",
|
||||||
|
);
|
||||||
append(
|
append(
|
||||||
ast.kind === "global-store" || ast.kind === "page-store"
|
ast.kind === "global-store" || ast.kind === "page-store"
|
||||||
? storeContract(ast)
|
? storeContract(ast)
|
||||||
@@ -332,6 +335,59 @@ export function virtualTypeScriptModule(
|
|||||||
for (const fn of functions) append(functionDeclaration(fn), fn.source);
|
for (const fn of functions) append(functionDeclaration(fn), fn.source);
|
||||||
append("}");
|
append("}");
|
||||||
}
|
}
|
||||||
|
const viewFunctions = new Map<string, RuntimeFunctionDecl["runtime"]>();
|
||||||
|
for (const fn of ast.runtimeFunctions) {
|
||||||
|
if (fn.runtime !== "server") viewFunctions.set(fn.name, fn.runtime);
|
||||||
|
}
|
||||||
|
for (const [name, runtime] of viewFunctions) {
|
||||||
|
append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View expressions execute in the client scope, but historically were not
|
||||||
|
// represented in the virtual TypeScript module. Validate them here so a
|
||||||
|
// misspelled state/function name cannot silently become `undefined` at
|
||||||
|
// runtime. Each/handler locals mirror the browser evaluator's bindings.
|
||||||
|
let bindingIndex = 0;
|
||||||
|
const appendViewBindings = (nodes: ViewNode[], locals: Set<string>): void => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "element") {
|
||||||
|
for (const attr of node.attrs) {
|
||||||
|
const expressions = attr.event
|
||||||
|
? [attr.value]
|
||||||
|
: [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim());
|
||||||
|
for (const expression of expressions) {
|
||||||
|
if (!expression.trim()) continue;
|
||||||
|
const declarations = [
|
||||||
|
...[...locals].map((name) => `declare const ${name}: any;`),
|
||||||
|
...(attr.event ? ["declare const payload: any;", "declare const event: Event;"] : []),
|
||||||
|
].join(" ");
|
||||||
|
append(
|
||||||
|
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${expression}); }`,
|
||||||
|
attr.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendViewBindings(node.children, locals);
|
||||||
|
} else if (node.type === "if") {
|
||||||
|
for (const branch of node.branches) {
|
||||||
|
if (branch.cond)
|
||||||
|
append(
|
||||||
|
`namespace __wrn_view_${bindingIndex++} { void (${branch.cond}); }`,
|
||||||
|
branch.cond,
|
||||||
|
);
|
||||||
|
appendViewBindings(branch.body, locals);
|
||||||
|
}
|
||||||
|
} else if (node.type === "each") {
|
||||||
|
append(`namespace __wrn_view_${bindingIndex++} { void (${node.list}); }`, node.list);
|
||||||
|
const nested = new Set(locals);
|
||||||
|
nested.add(node.item);
|
||||||
|
if (node.index) nested.add(node.index);
|
||||||
|
appendViewBindings(node.body, nested);
|
||||||
|
appendViewBindings(node.empty, locals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
appendViewBindings(ast.view, new Set());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ast,
|
ast,
|
||||||
@@ -464,7 +520,9 @@ function componentUsageDiagnostics(
|
|||||||
const shape = shapes.get(node.tag);
|
const shape = shapes.get(node.tag);
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const attributes = new Map(
|
const attributes = new Map(
|
||||||
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]),
|
node.attrs
|
||||||
|
.filter((attr) => !attr.event)
|
||||||
|
.map((attr) => [attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name, attr]),
|
||||||
);
|
);
|
||||||
const outputNames = new Set(shape.outputs.map((output) => output.name));
|
const outputNames = new Set(shape.outputs.map((output) => output.name));
|
||||||
const position = lineAt(source, `<${node.tag}`);
|
const position = lineAt(source, `<${node.tag}`);
|
||||||
@@ -501,7 +559,8 @@ function componentUsageDiagnostics(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
|
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
|
||||||
const prop = known.get(attr.name);
|
const propName = attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name;
|
||||||
|
const prop = known.get(propName);
|
||||||
if (!prop) {
|
if (!prop) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
code: "WRN-COMPONENT-UNKNOWN-PROP",
|
code: "WRN-COMPONENT-UNKNOWN-PROP",
|
||||||
|
|||||||
@@ -85,3 +85,30 @@ test("provides the framework context to page state expressions", () => {
|
|||||||
);
|
);
|
||||||
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false);
|
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects an out-of-scope name in a view event handler", () => {
|
||||||
|
const root = app();
|
||||||
|
const diagnostics = checkWrnSource(
|
||||||
|
`page AccountPage {
|
||||||
|
functions { client function save() {} }
|
||||||
|
view { <button @click="savve(missingValue)"></button> }
|
||||||
|
}`,
|
||||||
|
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
|
||||||
|
);
|
||||||
|
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts handler payload, loop locals, and the public useFetch helper", () => {
|
||||||
|
const root = app();
|
||||||
|
const diagnostics = checkWrnSource(
|
||||||
|
`page AccountPage {
|
||||||
|
state rows = [{ id: "1" }]
|
||||||
|
functions {
|
||||||
|
client async function save(id: string) { await useFetch("/api/rows/" + id, "POST", { id }) }
|
||||||
|
}
|
||||||
|
view { {#each rows as row}<button @click="save(row.id)"></button>{/each} }
|
||||||
|
}`,
|
||||||
|
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
|
||||||
|
);
|
||||||
|
expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user