Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbc3e43e1 | ||
|
|
55fed2177a | ||
|
|
8c609edd32 | ||
|
|
e898929193 | ||
|
|
18a1c40118 | ||
|
|
a20f143acb | ||
|
|
ac248f2bb0 | ||
|
|
0904a4efaa | ||
|
|
5b11b937bb | ||
|
|
f199385204 | ||
|
|
c0c2fa4595 | ||
|
|
cfdcdd00ad | ||
|
|
03d5cb6aa6 | ||
|
|
701acd828c | ||
|
|
b28b79c370 | ||
|
|
fdd0c9f847 | ||
|
|
7ad336b4dd | ||
|
|
d70e89230b | ||
|
|
7e6d8c3bc8 | ||
|
|
92c0920c9b | ||
|
|
a8d8ac386f | ||
|
|
2c8841cc5f | ||
|
|
b344d2a70a | ||
|
|
e83f0366ef | ||
|
|
d9e8f5be82 | ||
|
|
bd0c1317ff | ||
|
|
97801287f9 | ||
|
|
d77638131b | ||
|
|
609224591c | ||
|
|
6074d19c43 | ||
|
|
7301849a7f |
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.42",
|
"version": "0.8.45",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.11",
|
"version": "0.8.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.22",
|
"version": "0.8.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.38",
|
"version": "0.8.41",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -451,13 +451,14 @@
|
|||||||
},
|
},
|
||||||
"packages/language-server": {
|
"packages/language-server": {
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus-language-server": "src/server.ts",
|
"wrnexus-language-server": "src/server.ts",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/mcp": {
|
"packages/mcp": {
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react": {
|
"packages/react": {
|
||||||
"name": "@wrnexus/react",
|
"name": "@wrnexus/react",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -641,7 +642,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -967,6 +968,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
||||||
|
|
||||||
|
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||||
|
|
||||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||||
|
|
||||||
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
||||||
@@ -1387,6 +1390,14 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ Supported view features include:
|
|||||||
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
||||||
- comments and scoped styles
|
- comments and scoped styles
|
||||||
|
|
||||||
|
`{#if}` and `{#each}` are rendered on the server for the initial response and
|
||||||
|
remain reactive after hydration. Browser state changes switch conditional
|
||||||
|
branches and rerender loop rows, including the `{:empty}` branch.
|
||||||
|
|
||||||
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
||||||
boundaries.
|
boundaries.
|
||||||
|
|
||||||
|
|||||||
@@ -1040,7 +1040,8 @@
|
|||||||
"rpcManifest",
|
"rpcManifest",
|
||||||
"runtimeCapabilities",
|
"runtimeCapabilities",
|
||||||
"runtimeTypeOf",
|
"runtimeTypeOf",
|
||||||
"serializeIslandProps"
|
"serializeIslandProps",
|
||||||
|
"stripBrowserTypes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/content": {
|
"@wrnexus/content": {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.8
|
||||||
|
|
||||||
|
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||||
|
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||||
|
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||||
|
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||||
|
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||||
|
stale asynchronous edits, and duplicate client-side suggestions.
|
||||||
|
|
||||||
## 0.8.3
|
## 0.8.3
|
||||||
|
|
||||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||||
@@ -16,8 +25,8 @@
|
|||||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||||
diagnostics in packaged extension environments.
|
diagnostics in packaged extension environments.
|
||||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||||
consistently in the repository and extension development environment.
|
consistently in the repository and extension development environment.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,11 @@
|
|||||||
"maximum": 240,
|
"maximum": 240,
|
||||||
"scope": "resource",
|
"scope": "resource",
|
||||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||||
|
},
|
||||||
|
"wrnexus.html.autoClosingTags": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,9 @@
|
|||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.wrn": "wrn"
|
"*.wrn": "wrn"
|
||||||
},
|
},
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"wrn": "html"
|
||||||
|
},
|
||||||
"[wrn]": {
|
"[wrn]": {
|
||||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
@@ -276,13 +284,14 @@
|
|||||||
"check": "bun run build && bun run test && bun run validate",
|
"check": "bun run build && bun run test && bun run validate",
|
||||||
"vscode:prepublish": "bun run check",
|
"vscode:prepublish": "bun run check",
|
||||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
"publish": "vsce publish --no-dependencies",
|
||||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/vsce": "^3.9.2"
|
"@vscode/vsce": "^3.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-languageclient": "^10.1.0"
|
"vscode-languageclient": "^10.1.0",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const vscode = require("vscode");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-close tags as they are typed.
|
||||||
|
*
|
||||||
|
* LSP has no request for this, so the client watches document changes and asks
|
||||||
|
* the server whether the tag should close. The server owns the decision because
|
||||||
|
* void elements and already-closed tags must not be closed.
|
||||||
|
*/
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn") return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||||
|
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length) return;
|
||||||
|
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/") return;
|
||||||
|
// Every cursor must have typed the same trigger. A replaced selection
|
||||||
|
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Positions come from the editor's selections, not from the changes.
|
||||||
|
*
|
||||||
|
* A change's `range` is in coordinates from before the whole event, so with
|
||||||
|
* several cursors on one line every range after the first is short by the
|
||||||
|
* insertions preceding it. The selections have already been adjusted for
|
||||||
|
* the edit, so they are where the carets actually are.
|
||||||
|
*/
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length) return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||||
|
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(
|
||||||
|
positions.map((position) =>
|
||||||
|
client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||||
|
/*
|
||||||
|
* One insertSnippet call carries one snippet, and it is the only form that
|
||||||
|
* keeps every caret: inserting sequentially would collapse the selection to
|
||||||
|
* the first snippet and invalidate the remaining positions. Cursors that
|
||||||
|
* want different closing tags are therefore declined rather than
|
||||||
|
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||||
|
* this is for, produces one snippet for all of them.
|
||||||
|
*/
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||||
|
|
||||||
|
// The user may have kept typing during the round-trip; re-validate everything the
|
||||||
|
// insertion depends on before touching the document, since a stale offset would
|
||||||
|
// silently corrupt it.
|
||||||
|
if (vscode.window.activeTextEditor !== editor) return;
|
||||||
|
if (editor.document !== event.document) return;
|
||||||
|
if (editor.document.version !== documentVersion) return;
|
||||||
|
if (editor.selections.length !== positions.length) return;
|
||||||
|
if (
|
||||||
|
!editor.selections.every(
|
||||||
|
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAutoCloseTags };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
|
// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -285,6 +285,34 @@ function analyzeRuntimeRequirements(ast, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/compiler/src/browser-transpile.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Strip TypeScript from a generated browser module.
|
||||||
|
*
|
||||||
|
* A client function's body is emitted verbatim, so anything TypeScript-only
|
||||||
|
* inside one -- an annotated local, an `as` cast, a local interface -- reaches
|
||||||
|
* the browser module as TypeScript source. Codegen removes the types from the
|
||||||
|
* function's *signature*, which is what made this easy to miss: the emitted
|
||||||
|
* module looked transpiled, and only bodies carried types through.
|
||||||
|
*
|
||||||
|
* The artifact is written as `.mjs` and read back as plain JavaScript, so the
|
||||||
|
* failure surfaced as a syntax error pointing at generated code rather than at
|
||||||
|
* the `.wrn` line responsible.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.stripBrowserTypes = stripBrowserTypes;
|
||||||
|
let transpiler = null;
|
||||||
|
function stripBrowserTypes(code) {
|
||||||
|
const bun = globalThis.Bun;
|
||||||
|
if (!bun?.Transpiler) {
|
||||||
|
throw new Error("WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.");
|
||||||
|
}
|
||||||
|
transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" });
|
||||||
|
return transpiler.transformSync(code);
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -1261,7 +1289,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition = node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
const inner = node.children
|
const inner = node.children
|
||||||
@@ -2425,6 +2467,76 @@ function compileAttrValue(raw, ctx) {
|
|||||||
}
|
}
|
||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes) {
|
||||||
|
const render = (node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}))))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean)
|
||||||
|
return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(JSON.stringify([attribute.name.slice("class:".length), expression]))}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([name, attribute.value]))}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static")
|
||||||
|
return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||||
|
return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
function encodeClientControl(value) {
|
||||||
|
return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
function renderComponentIfNode(node, ctx) {
|
function renderComponentIfNode(node, ctx) {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||||
@@ -2436,7 +2548,11 @@ function renderComponentIfNode(node, ctx) {
|
|||||||
? bodyExpression
|
? bodyExpression
|
||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})));
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function renderComponentEachNode(node, ctx) {
|
function renderComponentEachNode(node, ctx) {
|
||||||
const item = node.item;
|
const item = node.item;
|
||||||
@@ -2448,7 +2564,7 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
};
|
};
|
||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
return ("${(() => { const __wl = Array.isArray(" +
|
const serverBody = "${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
list +
|
list +
|
||||||
@@ -2460,7 +2576,16 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}");
|
"`; })()}";
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function serverLoopLocalsAttribute(ctx) {
|
function serverLoopLocalsAttribute(ctx) {
|
||||||
const locals = [...(ctx.serverLocals ?? [])];
|
const locals = [...(ctx.serverLocals ?? [])];
|
||||||
@@ -3242,7 +3367,7 @@ function resolveWrnImports(declarations, importer, options) {
|
|||||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.stripBrowserTypes = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||||
exports.compileNativeWrnFile = compileNativeWrnFile;
|
exports.compileNativeWrnFile = compileNativeWrnFile;
|
||||||
exports.compileWrnFile = compileWrnFile;
|
exports.compileWrnFile = compileWrnFile;
|
||||||
exports.compile = compile;
|
exports.compile = compile;
|
||||||
@@ -3264,6 +3389,8 @@ var targets_ts_1 = require("./targets.js");
|
|||||||
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
||||||
var client_codegen_ts_1 = require("./client-codegen.js");
|
var client_codegen_ts_1 = require("./client-codegen.js");
|
||||||
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
||||||
|
var browser_transpile_ts_1 = require("./browser-transpile.js");
|
||||||
|
Object.defineProperty(exports, "stripBrowserTypes", { enumerable: true, get: function () { return browser_transpile_ts_1.stripBrowserTypes; } });
|
||||||
var server_codegen_ts_1 = require("./server-codegen.js");
|
var server_codegen_ts_1 = require("./server-codegen.js");
|
||||||
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
||||||
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
||||||
|
|||||||
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
|
|||||||
return depth > 0;
|
return depth > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an offset sits inside a `view { }` block.
|
||||||
|
*
|
||||||
|
* The language server owns completion there and returns a merged list, so this
|
||||||
|
* provider stands down to avoid VS Code concatenating two independent lists.
|
||||||
|
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
|
||||||
|
* string that never closes.
|
||||||
|
*/
|
||||||
|
function isInsideViewBlock(text, offset) {
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote = null;
|
||||||
|
let index = start;
|
||||||
|
for (; index < text.length && depth > 0; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) quote = char;
|
||||||
|
else if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") depth -= 1;
|
||||||
|
}
|
||||||
|
if (offset >= start && offset <= index) return true;
|
||||||
|
pattern.lastIndex = index;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isAfterWatchKeyword(document, position) {
|
function isAfterWatchKeyword(document, position) {
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
|
||||||
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function provideCompletionItems(document, position) {
|
function provideCompletionItems(document, position) {
|
||||||
|
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
@@ -707,6 +744,7 @@ module.exports = {
|
|||||||
extractProps,
|
extractProps,
|
||||||
extractRouteParams,
|
extractRouteParams,
|
||||||
extractStates,
|
extractStates,
|
||||||
|
isInsideViewBlock,
|
||||||
provideCompletionItems,
|
provideCompletionItems,
|
||||||
registerCompletionProvider,
|
registerCompletionProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// editors/vscode/src/auto-close-tags.js
|
||||||
|
var require_auto_close_tags = __commonJS((exports2, module2) => {
|
||||||
|
var vscode = require("vscode");
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn")
|
||||||
|
return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
|
||||||
|
return;
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length)
|
||||||
|
return;
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/")
|
||||||
|
return;
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
|
||||||
|
return;
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty))
|
||||||
|
return;
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character }
|
||||||
|
})));
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
|
||||||
|
return;
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0]))
|
||||||
|
return;
|
||||||
|
if (vscode.window.activeTextEditor !== editor)
|
||||||
|
return;
|
||||||
|
if (editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
if (editor.document.version !== documentVersion)
|
||||||
|
return;
|
||||||
|
if (editor.selections.length !== positions.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
module2.exports = { registerAutoCloseTags };
|
||||||
|
});
|
||||||
|
|
||||||
// editors/vscode/src/extension.js
|
// editors/vscode/src/extension.js
|
||||||
var path = require("node:path");
|
var path = require("node:path");
|
||||||
var vscode = require("vscode");
|
var vscode = require("vscode");
|
||||||
var { LanguageClient, TransportKind } = require_main5();
|
var { LanguageClient, TransportKind } = require_main5();
|
||||||
|
var { registerAutoCloseTags } = require_auto_close_tags();
|
||||||
var WRN_LANGUAGE_ID = "wrn";
|
var WRN_LANGUAGE_ID = "wrn";
|
||||||
var client;
|
var client;
|
||||||
async function recoverWrnLanguage(document) {
|
async function recoverWrnLanguage(document) {
|
||||||
@@ -22730,6 +22783,7 @@ async function activate(context) {
|
|||||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
const running = client;
|
const running = client;
|
||||||
@@ -22737,4 +22791,4 @@ async function deactivate() {
|
|||||||
if (running)
|
if (running)
|
||||||
await running.stop();
|
await running.stop();
|
||||||
}
|
}
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vscode = require("vscode");
|
const vscode = require("vscode");
|
||||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||||
|
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||||
|
|
||||||
const WRN_LANGUAGE_ID = "wrn";
|
const WRN_LANGUAGE_ID = "wrn";
|
||||||
/** @type {LanguageClient | undefined} */
|
/** @type {LanguageClient | undefined} */
|
||||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
|||||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||||
);
|
);
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
|||||||
if (running) await running.stop();
|
if (running) await running.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
translate(lineDelta, characterDelta) {
|
||||||
|
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||||
|
}
|
||||||
|
isEqual(other) {
|
||||||
|
return this.line === other.line && this.character === other.character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Selection {
|
||||||
|
constructor(active) {
|
||||||
|
this.active = active;
|
||||||
|
this.anchor = active;
|
||||||
|
this.isEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnippetString {
|
||||||
|
constructor(value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changeListener = null;
|
||||||
|
const host = {
|
||||||
|
Position,
|
||||||
|
Selection,
|
||||||
|
SnippetString,
|
||||||
|
workspace: {
|
||||||
|
onDidChangeTextDocument(listener) {
|
||||||
|
changeListener = listener;
|
||||||
|
return { dispose() {} };
|
||||||
|
},
|
||||||
|
getConfiguration() {
|
||||||
|
return { get: (_key, fallback) => fallback };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { activeTextEditor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost(host);
|
||||||
|
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the handler the way VS Code does: the document has already been
|
||||||
|
* updated and the carets moved by the time the change event fires.
|
||||||
|
*/
|
||||||
|
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||||
|
const inserted = [];
|
||||||
|
const asked = [];
|
||||||
|
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||||
|
const editor = {
|
||||||
|
document,
|
||||||
|
selections: carets.map((caret) => new Selection(caret)),
|
||||||
|
insertSnippet(snippet, positions) {
|
||||||
|
inserted.push({ value: snippet.value, positions });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
editor.selection = editor.selections[0];
|
||||||
|
host.window.activeTextEditor = editor;
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
sendRequest(_method, params) {
|
||||||
|
asked.push(params.position);
|
||||||
|
return Promise.resolve(snippetFor(params.position));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inserted,
|
||||||
|
asked,
|
||||||
|
fire: () =>
|
||||||
|
changeListener({
|
||||||
|
document,
|
||||||
|
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||||
|
contentChanges: carets.map(() => ({
|
||||||
|
text: typed,
|
||||||
|
rangeLength: 0,
|
||||||
|
range: { start: new Position(0, 0) },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("closes the tag at a single caret", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.equal(run.inserted[0].value, "$0</div>");
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[[1, 8]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the tag at every caret in one insertion", async () => {
|
||||||
|
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||||
|
// sequentially would collapse the selection to the first snippet.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||||
|
snippetFor: () => "$0</div>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.asked.length, 3);
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[1, 8],
|
||||||
|
[2, 8],
|
||||||
|
[3, 8],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||||
|
// Every contentChange above reports (0, 0). Using those would query and
|
||||||
|
// insert at the wrong offsets once more than one caret is on a line.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(4, 12), new Position(9, 3)],
|
||||||
|
snippetFor: () => "$0</p>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
run.asked.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[4, 12],
|
||||||
|
[9, 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when the carets want different closing tags", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when any caret has no tag to close", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a replaced selection", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await changeListener({
|
||||||
|
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||||
|
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost({
|
||||||
|
Position: class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Range: class Range {
|
||||||
|
constructor(start, end) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItem: class CompletionItem {
|
||||||
|
constructor(label, kind) {
|
||||||
|
this.label = label;
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItemKind: {
|
||||||
|
Event: 23,
|
||||||
|
Property: 10,
|
||||||
|
Function: 12,
|
||||||
|
Keyword: 14,
|
||||||
|
Variable: 13,
|
||||||
|
},
|
||||||
|
SnippetString: class SnippetString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MarkdownString: class MarkdownString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div>hello</div>
|
||||||
|
}
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a markup offset is inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a functions-block offset is not inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("<div");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: "<div>hello</div>" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 2, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert.equal(result.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("function go");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: " function go() {}" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 5, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||||
|
});
|
||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
// These extraction helpers are pure, but their module also registers VS Code
|
// These extraction helpers are pure, but their module also registers VS Code
|
||||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({});
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") return {};
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
test("extracts dynamic route params from filename", () => {
|
test("extracts dynamic route params from filename", () => {
|
||||||
const document = {
|
const document = {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") {
|
|
||||||
return {
|
|
||||||
Diagnostic: class Diagnostic {
|
Diagnostic: class Diagnostic {
|
||||||
constructor(range, message, severity) {
|
constructor(range, message, severity) {
|
||||||
this.range = range;
|
this.range = range;
|
||||||
@@ -22,10 +19,7 @@ Module._load = function load(request, parent, isMain) {
|
|||||||
this.end = end;
|
this.end = end;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
findTopLevelDeclaration,
|
findTopLevelDeclaration,
|
||||||
maskLeadingTrivia,
|
maskLeadingTrivia,
|
||||||
@@ -34,7 +28,7 @@ const {
|
|||||||
validateLayoutUsage,
|
validateLayoutUsage,
|
||||||
validateRootMembers,
|
validateRootMembers,
|
||||||
} = require("../src/diagnostics");
|
} = require("../src/diagnostics");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
function mockDocument() {
|
function mockDocument() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ try {
|
|||||||
readFileSync(join(root, rel), "utf8");
|
readFileSync(join(root, rel), "utf8");
|
||||||
ok(`Marketplace document exists: ${rel}`);
|
ok(`Marketplace document exists: ${rel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||||
|
emmetLanguages?.wrn === "html"
|
||||||
|
? ok("Emmet is mapped for wrn documents")
|
||||||
|
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||||
|
|
||||||
|
const autoClose =
|
||||||
|
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||||
|
autoClose?.type === "boolean" && autoClose?.default === true
|
||||||
|
? ok("auto-closing tags setting is contributed")
|
||||||
|
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||||
|
|
||||||
|
manifest.dependencies?.["vscode-html-languageservice"]
|
||||||
|
? ok("HTML language service ships as a runtime dependency")
|
||||||
|
: bad("HTML language service ships as a runtime dependency");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bad("Marketplace metadata", e.message);
|
bad("Marketplace metadata", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||||
|
*
|
||||||
|
* These files run under `node --test` (see the package's test script), where
|
||||||
|
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||||
|
* root also picks them up by filename, and Bun resolves `require` through its
|
||||||
|
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||||
|
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||||
|
* module covers that case, leaving one shim that works under both runners.
|
||||||
|
*
|
||||||
|
* Returns a function restoring the original loader.
|
||||||
|
*/
|
||||||
|
function installVsCodeHost(stub) {
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
if (typeof Bun !== "undefined") {
|
||||||
|
require("bun").plugin({
|
||||||
|
name: "vscode-host-stub",
|
||||||
|
setup(build) {
|
||||||
|
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === "vscode") return stub;
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { installVsCodeHost };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.42",
|
"version": "0.8.45",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
type DeploymentRuntime,
|
type DeploymentRuntime,
|
||||||
runtimeCapabilities,
|
runtimeCapabilities,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
import {
|
import {
|
||||||
loadAppConfig,
|
loadAppConfig,
|
||||||
@@ -324,7 +325,9 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(out, code, "utf8");
|
writeFileSync(out, code, "utf8");
|
||||||
writeFileSync(clientEntry, browserCode, "utf8");
|
// The entry is .mjs, so anything TypeScript left in a client function
|
||||||
|
// body would be read back as JavaScript and fail to parse.
|
||||||
|
writeFileSync(clientEntry, stripBrowserTypes(browserCode), "utf8");
|
||||||
const browserResult = await Bun.build({
|
const browserResult = await Bun.build({
|
||||||
entrypoints: [clientEntry],
|
entrypoints: [clientEntry],
|
||||||
target: "browser",
|
target: "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.11",
|
"version": "0.8.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Strip TypeScript from a generated browser module.
|
||||||
|
*
|
||||||
|
* A client function's body is emitted verbatim, so anything TypeScript-only
|
||||||
|
* inside one -- an annotated local, an `as` cast, a local interface -- reaches
|
||||||
|
* the browser module as TypeScript source. Codegen removes the types from the
|
||||||
|
* function's *signature*, which is what made this easy to miss: the emitted
|
||||||
|
* module looked transpiled, and only bodies carried types through.
|
||||||
|
*
|
||||||
|
* The artifact is written as `.mjs` and read back as plain JavaScript, so the
|
||||||
|
* failure surfaced as a syntax error pointing at generated code rather than at
|
||||||
|
* the `.wrn` line responsible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let transpiler: { transformSync(code: string): string } | null = null;
|
||||||
|
|
||||||
|
export function stripBrowserTypes(code: string): string {
|
||||||
|
const bun = (
|
||||||
|
globalThis as unknown as {
|
||||||
|
Bun?: { Transpiler: new (options: unknown) => { transformSync(code: string): string } };
|
||||||
|
}
|
||||||
|
).Bun;
|
||||||
|
|
||||||
|
if (!bun?.Transpiler) {
|
||||||
|
throw new Error(
|
||||||
|
"WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" });
|
||||||
|
|
||||||
|
return transpiler.transformSync(code);
|
||||||
|
}
|
||||||
@@ -593,7 +593,22 @@ function renderNode(
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition =
|
||||||
|
node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
@@ -2037,6 +2052,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
|
|||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes: ViewNode[]): string {
|
||||||
|
const render = (node: ViewNode): string => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression: string) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(
|
||||||
|
encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(
|
||||||
|
encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean) return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([attribute.name.slice("class:".length), expression]),
|
||||||
|
)}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([name, attribute.value]),
|
||||||
|
)}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static") return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeClientControl(value: unknown): string {
|
||||||
|
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
|
|
||||||
@@ -2051,7 +2147,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
|||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||||
@@ -2067,7 +2170,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
|
|
||||||
return (
|
const serverBody =
|
||||||
"${(() => { const __wl = Array.isArray(" +
|
"${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
@@ -2080,8 +2183,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}"
|
"`; })()}";
|
||||||
);
|
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
export { generate } from "./codegen.ts";
|
export { generate } from "./codegen.ts";
|
||||||
export { generateTargets } from "./targets.ts";
|
export { generateTargets } from "./targets.ts";
|
||||||
export { generateBrowserModule } from "./client-codegen.ts";
|
export { generateBrowserModule } from "./client-codegen.ts";
|
||||||
|
export { stripBrowserTypes } from "./browser-transpile.ts";
|
||||||
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
||||||
export { generateDeclarations } from "./type-codegen.ts";
|
export { generateDeclarations } from "./type-codegen.ts";
|
||||||
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generateTargets } from "../src/targets.ts";
|
||||||
|
import { stripBrowserTypes } from "../src/browser-transpile.ts";
|
||||||
|
|
||||||
|
/** Build the browser module for a page whose client function body is TypeScript. */
|
||||||
|
function browserModuleFor(body: string): string {
|
||||||
|
const source = `page Repro {
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
${body}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main><button @click="run()">go</button></main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return generateTargets(parse(source)).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The artifact is written as .mjs, so this is how the runtime reads it back. */
|
||||||
|
function parsesAsJavaScript(code: string): boolean {
|
||||||
|
try {
|
||||||
|
new Function(code.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a client function body keeps its TypeScript in the generated module", () => {
|
||||||
|
// Codegen strips the signature's types but copies the body verbatim, which is
|
||||||
|
// what made this easy to miss. Guarding the premise the fix rests on.
|
||||||
|
const generated = browserModuleFor(` const requestBody: Record<string, unknown> = {}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("const requestBody: Record<string, unknown>");
|
||||||
|
expect(parsesAsJavaScript(generated)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stripping types makes an annotated client function body valid JavaScript", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const requestBody: Record<string, unknown> = {}
|
||||||
|
requestBody.q = "x"`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).not.toContain("Record<string, unknown>");
|
||||||
|
expect(stripped).toContain("requestBody.q");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("casts, generics and local interfaces survive stripping", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` interface Local { a: string }
|
||||||
|
const names: string[] = ["a"]
|
||||||
|
const typed = { a: "x" } as Local
|
||||||
|
const total = (1 as number) + names.length
|
||||||
|
console.log(typed.a, total)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).toContain("console.log");
|
||||||
|
expect(stripped).not.toContain("interface Local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the module's exported bindings are preserved", () => {
|
||||||
|
// A transpile that dropped one of these would break hydration silently.
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const value: number = 1
|
||||||
|
console.log(value)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const binding of [
|
||||||
|
"__wrnexusClientFunctions",
|
||||||
|
"__wrnexusClientState",
|
||||||
|
"__wrnexusOutputs",
|
||||||
|
"__wrnexusImportedBindings",
|
||||||
|
"bindClientScope",
|
||||||
|
]) {
|
||||||
|
expect(stripped).toContain(binding);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body with no TypeScript is left working", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const plain = { a: 1 }
|
||||||
|
console.log(plain.a)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).toContain("console.log");
|
||||||
|
});
|
||||||
@@ -1053,6 +1053,23 @@ component Banner {
|
|||||||
expect(output).toContain("Visible");
|
expect(output).toContain("Visible");
|
||||||
expect(output).toContain("Hidden");
|
expect(output).toContain("Hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("if and each blocks emit browser control metadata while preserving SSR", () => {
|
||||||
|
const output = generate(
|
||||||
|
parse(`component ClientBlocks {
|
||||||
|
state open = false
|
||||||
|
state items = ["a"]
|
||||||
|
view {
|
||||||
|
{#if open}<p>Open</p>{:else}<p>Closed</p>{/if}
|
||||||
|
{#each items as item}<span>{item}</span>{:empty}<i>Empty</i>{/each}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).toContain("data-wrn-if=");
|
||||||
|
expect(output).toContain("data-wrn-each=");
|
||||||
|
expect(output).toContain("Array.isArray(items)");
|
||||||
|
});
|
||||||
test("component array props support each blocks", () => {
|
test("component array props support each blocks", () => {
|
||||||
const output = generate(
|
const output = generate(
|
||||||
parse(`
|
parse(`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.22",
|
"version": "0.8.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
|
|||||||
var emitPinInputEvent = bridge.emitPinInputEvent;
|
var emitPinInputEvent = bridge.emitPinInputEvent;
|
||||||
var parseScopeDecl = bridge.parseScopeDecl;
|
var parseScopeDecl = bridge.parseScopeDecl;
|
||||||
var warnOnce = bridge.warn || function () {};
|
var warnOnce = bridge.warn || function () {};
|
||||||
|
// The extracted sections use these the same way the core runtime does, and
|
||||||
|
// this bundle is a separate IIFE, so it needs its own copies.
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
${sections}
|
${sections}
|
||||||
function hydrate(root) {
|
function hydrate(root) {
|
||||||
var host = root || document;
|
var host = root || document;
|
||||||
|
|||||||
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var behaviorObserver;
|
var behaviorObserver;
|
||||||
var clientModuleCache = new Map();
|
var clientModuleCache = new Map();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two builtin chains the runtime reaches for constantly. Aliasing them is
|
||||||
|
* not only shorter: hasOwn keeps prototype keys from reading as data, and
|
||||||
|
* toArray is needed because a NodeList is not an Array.
|
||||||
|
*/
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* data-wrn-class-* and data-wrn-bind-* both carry a JSON ["name","expression"]
|
||||||
|
* pair. Malformed markup yields null so every caller bails the same way
|
||||||
|
* rather than each repeating the parse and the shape check.
|
||||||
|
*/
|
||||||
|
function pairBinding(value) {
|
||||||
|
var parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed && parsed.length === 2 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Globals the expression engine resolves for client code. Kept as explicit
|
* Globals the expression engine resolves for client code. Kept as explicit
|
||||||
* tables rather than falling through to window[name]: an implicit fallback
|
* lists rather than falling through to window[name]: an implicit fallback
|
||||||
* would let any expression reach every global on the page (and would make a
|
* would let any expression reach every global on the page (and would make a
|
||||||
* typo silently resolve to some unrelated window property) -- these lists
|
* typo silently resolve to some unrelated window property) -- these lists
|
||||||
* say exactly what client code may reach.
|
* say exactly what client code may reach.
|
||||||
*
|
*
|
||||||
* dialogGlobals must be bound to window or the browser throws
|
* Prototype-less so a name like "toString" or "constructor" is a miss
|
||||||
* "Illegal invocation" when they are called detached.
|
* rather than a hit on Object.prototype.
|
||||||
*/
|
*/
|
||||||
var dialogGlobals = {
|
function nameSet(names) {
|
||||||
alert: 1,
|
var set = Object.create(null);
|
||||||
confirm: 1,
|
|
||||||
prompt: 1,
|
|
||||||
fetch: 1,
|
|
||||||
print: 1,
|
|
||||||
open: 1,
|
|
||||||
scrollTo: 1,
|
|
||||||
scrollBy: 1,
|
|
||||||
matchMedia: 1,
|
|
||||||
getComputedStyle: 1,
|
|
||||||
structuredClone: 1,
|
|
||||||
queueMicrotask: 1,
|
|
||||||
btoa: 1,
|
|
||||||
atob: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Language builtins. Wrapped in thunks so referencing one that a given
|
names.split(" ").forEach(function (name) {
|
||||||
// engine lacks cannot throw at table-definition time.
|
set[name] = 1;
|
||||||
var jsGlobals = {
|
});
|
||||||
Object: function () { return Object; },
|
|
||||||
Boolean: function () { return Boolean; },
|
return set;
|
||||||
RegExp: function () { return RegExp; },
|
}
|
||||||
Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
|
|
||||||
Set: function () { return typeof Set === "undefined" ? undefined : Set; },
|
/*
|
||||||
Map: function () { return typeof Map === "undefined" ? undefined : Map; },
|
* Called with window as the receiver. Detached, the browser throws
|
||||||
Error: function () { return Error; },
|
* "Illegal invocation" for these.
|
||||||
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
|
*/
|
||||||
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
|
var boundWindowGlobals = nameSet(
|
||||||
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
|
"alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
|
||||||
parseInt: function () { return parseInt; },
|
" getComputedStyle structuredClone queueMicrotask btoa atob" +
|
||||||
parseFloat: function () { return parseFloat; },
|
" setTimeout clearTimeout setInterval clearInterval" +
|
||||||
isNaN: function () { return isNaN; },
|
" requestAnimationFrame cancelAnimationFrame",
|
||||||
isFinite: function () { return isFinite; },
|
);
|
||||||
encodeURIComponent: function () { return encodeURIComponent; },
|
|
||||||
decodeURIComponent: function () { return decodeURIComponent; },
|
/*
|
||||||
encodeURI: function () { return encodeURI; },
|
* Language builtins and other realm globals, read off globalThis. Naming
|
||||||
decodeURI: function () { return decodeURI; },
|
* them rather than referencing them directly means one an engine lacks
|
||||||
NaN: function () { return NaN; },
|
* resolves to undefined instead of throwing where the table is defined.
|
||||||
Infinity: function () { return Infinity; },
|
*/
|
||||||
undefined: function () { return undefined; },
|
var ambientGlobals = nameSet(
|
||||||
};
|
"Object Boolean RegExp Promise Set Map Error Symbol BigInt Intl parseInt" +
|
||||||
|
" parseFloat isNaN isFinite encodeURIComponent decodeURIComponent" +
|
||||||
|
" encodeURI decodeURI NaN Infinity undefined Array Number String Math" +
|
||||||
|
" JSON Date URL",
|
||||||
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* toast(...) -- raise a notification from any client expression.
|
* toast(...) -- raise a notification from any client expression.
|
||||||
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (!window.toast) window.toast = toastApi;
|
if (!window.toast) window.toast = toastApi;
|
||||||
|
|
||||||
// Read straight off window, no binding needed (objects, not functions).
|
// Read straight off window, no binding needed (objects, not functions).
|
||||||
var windowGlobals = {
|
var windowGlobals = nameSet(
|
||||||
localStorage: 1,
|
"localStorage sessionStorage screen performance crypto CustomEvent Event" +
|
||||||
sessionStorage: 1,
|
" FormData URLSearchParams AbortController Notification" +
|
||||||
screen: 1,
|
" IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
|
||||||
performance: 1,
|
" innerWidth innerHeight scrollX scrollY location history navigator",
|
||||||
crypto: 1,
|
);
|
||||||
CustomEvent: 1,
|
|
||||||
Event: 1,
|
|
||||||
FormData: 1,
|
|
||||||
URLSearchParams: 1,
|
|
||||||
AbortController: 1,
|
|
||||||
Notification: 1,
|
|
||||||
IntersectionObserver: 1,
|
|
||||||
ResizeObserver: 1,
|
|
||||||
MutationObserver: 1,
|
|
||||||
devicePixelRatio: 1,
|
|
||||||
innerWidth: 1,
|
|
||||||
innerHeight: 1,
|
|
||||||
scrollX: 1,
|
|
||||||
scrollY: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reportDiagnostic(code, message, element, detail) {
|
function reportDiagnostic(code, message, element, detail) {
|
||||||
var payload = {
|
var payload = {
|
||||||
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var serverProxy = new Proxy({}, {
|
var serverProxy = new Proxy({}, {
|
||||||
get: function (_target, property) {
|
get: function (_target, property) {
|
||||||
return function () {
|
return function () {
|
||||||
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
|
return callServerFunction(componentRpcName, String(property), toArray(arguments));
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1111,7 +1116,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "server") return serverProxy;
|
if (name === "server") return serverProxy;
|
||||||
if (name === "props") return propsProxy;
|
if (name === "props") return propsProxy;
|
||||||
if (name === "refs") return refsProxy;
|
if (name === "refs") return refsProxy;
|
||||||
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
|
if (hasOwn(moduleBindings, name)) return moduleBindings[name];
|
||||||
if (name === "$emit") {
|
if (name === "$emit") {
|
||||||
return function (eventName, detail) {
|
return function (eventName, detail) {
|
||||||
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
||||||
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "window") return window;
|
if (name === "window") return window;
|
||||||
if (name === "document") return document;
|
if (name === "document") return document;
|
||||||
if (name === "console") return console;
|
if (name === "console") return console;
|
||||||
if (name === "Array") return Array;
|
|
||||||
if (name === "Number") return Number;
|
|
||||||
if (name === "String") return String;
|
|
||||||
if (name === "Math") return Math;
|
|
||||||
if (name === "JSON") return JSON;
|
|
||||||
if (name === "Date") return Date;
|
|
||||||
if (name === "URL") return URL;
|
|
||||||
if (name === "location") return window.location;
|
|
||||||
if (name === "history") return window.history;
|
|
||||||
if (name === "navigator") return window.navigator;
|
|
||||||
if (name === "$route" || name === "route") {
|
if (name === "$route" || name === "route") {
|
||||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||||
return routeValue.get();
|
return routeValue.get();
|
||||||
}
|
}
|
||||||
if (name === "setTimeout") return window.setTimeout.bind(window);
|
|
||||||
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
|
||||||
if (name === "setInterval") return window.setInterval.bind(window);
|
|
||||||
if (name === "clearInterval") return window.clearInterval.bind(window);
|
|
||||||
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
|
|
||||||
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
|
|
||||||
/*
|
/*
|
||||||
* Ordinary browser and language globals.
|
* Ordinary browser and language globals.
|
||||||
*
|
*
|
||||||
@@ -1156,11 +1145,11 @@ 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 (dialogGlobals[name] && typeof window[name] === "function") {
|
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
||||||
return window[name].bind(window);
|
return window[name].bind(window);
|
||||||
}
|
}
|
||||||
if (jsGlobals[name]) {
|
if (ambientGlobals[name]) {
|
||||||
var builtin = jsGlobals[name]();
|
var builtin = globalThis[name];
|
||||||
if (builtin !== undefined) return builtin;
|
if (builtin !== undefined) return builtin;
|
||||||
}
|
}
|
||||||
if (windowGlobals[name]) {
|
if (windowGlobals[name]) {
|
||||||
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readScope(name) {
|
function readScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
if (computing.has(name)) {
|
if (computing.has(name)) {
|
||||||
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (currentRenderer) sig.subscribe(currentRenderer);
|
if (currentRenderer) sig.subscribe(currentRenderer);
|
||||||
return sig.get();
|
return sig.get();
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function peekScope(name) {
|
function peekScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
}
|
}
|
||||||
if (signals[name]) return signals[name].get();
|
if (signals[name]) return signals[name].get();
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function evalExpr(expr, locals) {
|
function evalExpr(expr, locals) {
|
||||||
return evaluateExpression(expr, function (name) {
|
return evaluateExpression(expr, function (name) {
|
||||||
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
|
if (locals && hasOwn(locals, name)) {
|
||||||
return locals[name];
|
return locals[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
source,
|
source,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
|
locals = locals || Object.create(null);
|
||||||
|
|
||||||
return batchUpdates(function () {
|
return batchUpdates(function () {
|
||||||
var statements =
|
var statements =
|
||||||
splitStatements(source);
|
splitStatements(source);
|
||||||
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name) {
|
function (name) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name, value) {
|
function (name, value) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals,
|
locals,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
function (name, value) {
|
||||||
|
locals[name] = value;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.returned) {
|
if (result.returned) {
|
||||||
@@ -1501,19 +1493,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
return type + ":" + String(value);
|
return type + ":" + String(value);
|
||||||
}
|
}
|
||||||
function fillMustache(str, itemEval) {
|
|
||||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
|
||||||
var e = (d || s).trim();
|
|
||||||
try { return String(itemEval(e)); } catch (err) { return ""; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function hydrateItem(
|
function hydrateItem(
|
||||||
root,
|
root,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
function localRead(name) {
|
function localRead(name) {
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(
|
hasOwn(
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1597,7 +1583,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (node !== root && insideNestedLoop(node)) return;
|
if (node !== root && insideNestedLoop(node)) return;
|
||||||
|
|
||||||
var attributes =
|
var attributes =
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
node.attributes,
|
node.attributes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1611,24 +1597,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (
|
if (
|
||||||
attribute.name === "data-text"
|
attribute.name === "data-text"
|
||||||
) {
|
) {
|
||||||
|
(function (textNode, textExpression) {
|
||||||
|
var runText = reactive(function () {
|
||||||
try {
|
try {
|
||||||
var textValue =
|
var textValue = itemEval(textExpression);
|
||||||
itemEval(attribute.value);
|
textNode.textContent = textValue == null ? "" : String(textValue);
|
||||||
|
|
||||||
node.textContent =
|
|
||||||
textValue == null
|
|
||||||
? ""
|
|
||||||
: String(textValue);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[wrnexus] data-for text binding failed for '" +
|
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
|
||||||
attribute.value +
|
|
||||||
"'",
|
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
textNode.textContent = "";
|
||||||
node.textContent = "";
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
runText();
|
||||||
|
})(node, attribute.value);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1642,28 +1625,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
"data-wrn-class-",
|
"data-wrn-class-",
|
||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
var classBinding;
|
var classBinding = pairBinding(attribute.value);
|
||||||
|
|
||||||
try {
|
if (!classBinding) return;
|
||||||
classBinding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
var className = classBinding[0];
|
||||||
!classBinding ||
|
var classExpression = classBinding[1];
|
||||||
classBinding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className =
|
|
||||||
classBinding[0];
|
|
||||||
|
|
||||||
var classExpression =
|
|
||||||
classBinding[1];
|
|
||||||
|
|
||||||
var classEnabled = false;
|
var classEnabled = false;
|
||||||
|
|
||||||
@@ -1693,28 +1660,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
node.removeAttribute(attribute.name);
|
node.removeAttribute(attribute.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(attribute.value);
|
||||||
binding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attributeName =
|
var attributeName = binding[0];
|
||||||
binding[0];
|
var attributeTemplate = binding[1];
|
||||||
|
|
||||||
var attributeTemplate =
|
|
||||||
binding[1];
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Reactive, not resolved once. The expression can read component
|
* Reactive, not resolved once. The expression can read component
|
||||||
@@ -1782,7 +1734,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
eventLocals.event = event;
|
eventLocals.event = event;
|
||||||
eventLocals.$event = event;
|
eventLocals.$event = event;
|
||||||
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
eventLocals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -1934,8 +1886,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
// Hand every nested loop its own renderer, with this item in scope.
|
// Hand every nested loop its own renderer, with this item in scope.
|
||||||
if (root.querySelectorAll) {
|
if (root.querySelectorAll) {
|
||||||
Array.prototype.slice
|
toArray(root.querySelectorAll("[data-for]"))
|
||||||
.call(root.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (nested) {
|
.forEach(function (nested) {
|
||||||
// Only the outermost nested templates: deeper ones are set up by
|
// Only the outermost nested templates: deeper ones are set up by
|
||||||
// their own parent when it renders.
|
// their own parent when it renders.
|
||||||
@@ -1954,6 +1905,155 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compiled if/each blocks keep their SSR result in the custom
|
||||||
|
// element and carry an inert, base64-encoded template for later browser
|
||||||
|
// updates. The first reactive pass only subscribes to dependencies, so
|
||||||
|
// hydration does not throw away server DOM. Subsequent state changes
|
||||||
|
// materialize the appropriate branch/rows and hydrate their bindings.
|
||||||
|
function decodeControlDefinition(value) {
|
||||||
|
try {
|
||||||
|
var binary = window.atob(value || "");
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return JSON.parse(new TextDecoder("utf-8").decode(bytes));
|
||||||
|
} catch (error) {
|
||||||
|
reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupControlBlock(block, outerLocals) {
|
||||||
|
if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return;
|
||||||
|
block.__wrnexusControl = true;
|
||||||
|
var inherited = outerLocals || decodeLoopLocals(block);
|
||||||
|
var rangeEnd = null;
|
||||||
|
if (block.tagName && block.tagName.toLowerCase() === "template") {
|
||||||
|
var depth = 0;
|
||||||
|
for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) {
|
||||||
|
if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue;
|
||||||
|
if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++;
|
||||||
|
if (sibling.hasAttribute("data-wrn-control-end")) {
|
||||||
|
if (depth === 0) { rangeEnd = sibling; break; }
|
||||||
|
depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rangeEnd) return;
|
||||||
|
}
|
||||||
|
var ifDefinition = block.hasAttribute("data-wrn-if")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-if"))
|
||||||
|
: null;
|
||||||
|
var eachDefinition = block.hasAttribute("data-wrn-each")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-each"))
|
||||||
|
: null;
|
||||||
|
/*
|
||||||
|
* Skip the first reactive pass only when hydrating server DOM.
|
||||||
|
*
|
||||||
|
* A block that arrived with the server HTML is already rendered, so
|
||||||
|
* redrawing on the first pass would discard it. A block created later by
|
||||||
|
* an outer block's rerender has no server DOM — outerLocals is how it
|
||||||
|
* receives its enclosing loop's scope, and is only ever set on that path.
|
||||||
|
* Skipping its first pass leaves it permanently empty, because its
|
||||||
|
* dependencies never change again to trigger a second one.
|
||||||
|
*/
|
||||||
|
var firstRun = !outerLocals;
|
||||||
|
|
||||||
|
function controlRead(name) {
|
||||||
|
return hasOwn(inherited, name) ? inherited[name] : readScope(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlEval(expression, locals) {
|
||||||
|
return evaluateExpression(expression, function (name) {
|
||||||
|
return locals && hasOwn(locals, name)
|
||||||
|
? locals[name]
|
||||||
|
: controlRead(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearControlContent() {
|
||||||
|
if (!rangeEnd) { block.innerHTML = ""; return; }
|
||||||
|
while (block.nextSibling && block.nextSibling !== rangeEnd) {
|
||||||
|
block.parentNode.removeChild(block.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendControlContent(markup, locals) {
|
||||||
|
var template = document.createElement("template");
|
||||||
|
template.innerHTML = markup || "";
|
||||||
|
var fragment = template.content;
|
||||||
|
var elements = toArray(fragment.childNodes).filter(function (node) {
|
||||||
|
return node.nodeType === 1;
|
||||||
|
});
|
||||||
|
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
|
||||||
|
else block.appendChild(fragment);
|
||||||
|
elements.forEach(function (node) { hydrateItem(node, locals || inherited); });
|
||||||
|
elements.forEach(function (node) {
|
||||||
|
var controls = [];
|
||||||
|
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
|
||||||
|
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
|
||||||
|
controls.forEach(function (nested) {
|
||||||
|
if (nested.__wrnexusControl) return;
|
||||||
|
/*
|
||||||
|
* Run the new block now rather than waiting for a sweep.
|
||||||
|
*
|
||||||
|
* reactive() only registers an effect; effects execute when
|
||||||
|
* renderAll sweeps the list. A state change runs just the affected
|
||||||
|
* effects, so a block registered during that rerender is queued and
|
||||||
|
* never invoked -- it would stay empty for the life of the page.
|
||||||
|
*/
|
||||||
|
var runNested = setupControlBlock(nested, locals || inherited);
|
||||||
|
if (runNested) runNested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactive(function () {
|
||||||
|
/*
|
||||||
|
* A block removed from the DOM keeps its effect in the renderers list,
|
||||||
|
* so a later sweep would run it against a detached node and throw --
|
||||||
|
* aborting the sweep, leaving every later effect unrendered. Skip it.
|
||||||
|
*/
|
||||||
|
if (!block.parentNode) return;
|
||||||
|
if (ifDefinition) {
|
||||||
|
var selected = null;
|
||||||
|
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
|
||||||
|
var branch = ifDefinition[branchIndex];
|
||||||
|
if (branch.cond === null || !!controlEval(branch.cond, inherited)) {
|
||||||
|
selected = branch;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
appendControlContent(selected ? selected.body : "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eachDefinition) return;
|
||||||
|
var list = controlEval(eachDefinition.list, inherited);
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
if (list.length === 0) {
|
||||||
|
appendControlContent(eachDefinition.empty || "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var itemIndex = 0; itemIndex < list.length; itemIndex++) {
|
||||||
|
var rowLocals = {};
|
||||||
|
Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; });
|
||||||
|
rowLocals[eachDefinition.item] = list[itemIndex];
|
||||||
|
if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex;
|
||||||
|
appendControlContent(eachDefinition.body || "", rowLocals);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
|
||||||
|
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
|
||||||
|
setupControlBlock(block, null);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set up one [data-for] template. Extracted from an inline forEach so it
|
* Set up one [data-for] template. Extracted from an inline forEach so it
|
||||||
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
||||||
@@ -1981,7 +2081,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopRead(name) {
|
function loopRead(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
|
if (hasOwn(inherited, name)) {
|
||||||
return inherited[name];
|
return inherited[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -2154,7 +2254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
rawKey = evaluateExpression(
|
rawKey = evaluateExpression(
|
||||||
keyExpression,
|
keyExpression,
|
||||||
function (name) {
|
function (name) {
|
||||||
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
return hasOwn(keyedLocals, name)
|
||||||
? keyedLocals[name]
|
? keyedLocals[name]
|
||||||
: loopRead(name);
|
: loopRead(name);
|
||||||
},
|
},
|
||||||
@@ -2227,8 +2327,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-for]"))
|
||||||
.call(el.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (tpl) {
|
.forEach(function (tpl) {
|
||||||
// Only top-level templates here; nested ones are connected by the item
|
// Only top-level templates here; nested ones are connected by the item
|
||||||
// that contains them, once it has values to give them.
|
// that contains them, once it has values to give them.
|
||||||
@@ -2324,24 +2423,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Conditional class bindings emitted as:
|
// Conditional class bindings emitted as:
|
||||||
// data-wrn-class-*='["class-name","expression"]'
|
// data-wrn-class-*='["class-name","expression"]'
|
||||||
var classBindNodes = [el].concat(
|
var classBindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(el.querySelectorAll("*")),
|
toArray(el.querySelectorAll("*")),
|
||||||
);
|
);
|
||||||
|
|
||||||
classBindNodes.forEach(function (node) {
|
classBindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
|
toArray(node.attributes).forEach(function (marker) {
|
||||||
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
||||||
|
|
||||||
var binding;
|
var binding = pairBinding(marker.value);
|
||||||
|
|
||||||
try {
|
if (!binding) return;
|
||||||
binding = JSON.parse(marker.value);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
|
|
||||||
var className = binding[0];
|
var className = binding[0];
|
||||||
var expression = binding[1];
|
var expression = binding[1];
|
||||||
@@ -2370,7 +2463,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
||||||
// state changes to update type, aria-*, class, href, and other attributes.
|
// state changes to update type, aria-*, class, href, and other attributes.
|
||||||
var bindNodes = [el].concat(
|
var bindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
el.querySelectorAll("*"),
|
el.querySelectorAll("*"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2378,8 +2471,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
bindNodes.forEach(function (node) {
|
bindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(node.attributes)
|
||||||
.call(node.attributes)
|
|
||||||
.forEach(function (marker) {
|
.forEach(function (marker) {
|
||||||
if (
|
if (
|
||||||
marker.name.indexOf(
|
marker.name.indexOf(
|
||||||
@@ -2390,22 +2482,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
node.removeAttribute(marker.name);
|
node.removeAttribute(marker.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(marker.value);
|
||||||
binding = JSON.parse(
|
|
||||||
marker.value,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = binding[0];
|
var name = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
@@ -2497,10 +2577,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event handlers on elements, window, and document.
|
// Event handlers on elements, window, and document.
|
||||||
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
var nodes = [el].concat(toArray(el.querySelectorAll("*")));
|
||||||
nodes.forEach(function (node) {
|
nodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-on-") !== 0) return;
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||||
|
|
||||||
var rawName = attr.name.slice("data-on-".length);
|
var rawName = attr.name.slice("data-on-".length);
|
||||||
@@ -2527,7 +2607,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
locals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -2576,8 +2656,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
||||||
// two cases stay distinguishable, and this scope claims every one that
|
// two cases stay distinguishable, and this scope claims every one that
|
||||||
// sits on a component it directly mounts.
|
// sits on a component it directly mounts.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-wrn-events]"))
|
||||||
.call(el.querySelectorAll("[data-wrn-events]"))
|
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
var componentRoot = closestScope(node);
|
var componentRoot = closestScope(node);
|
||||||
if (!componentRoot || componentRoot === el) return;
|
if (!componentRoot || componentRoot === el) return;
|
||||||
@@ -2594,7 +2673,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
node.__wrnexusOutputHandlers ||
|
node.__wrnexusOutputHandlers ||
|
||||||
(node.__wrnexusOutputHandlers = {});
|
(node.__wrnexusOutputHandlers = {});
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
||||||
|
|
||||||
var outName = attr.name.slice("data-wrn-out-".length);
|
var outName = attr.name.slice("data-wrn-out-".length);
|
||||||
@@ -2632,7 +2711,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload =
|
locals.payload =
|
||||||
event && Object.prototype.hasOwnProperty.call(event, "detail")
|
event && hasOwn(event, "detail")
|
||||||
? event.detail
|
? event.detail
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
@@ -2654,16 +2733,14 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Prop expressions belong to the parent that mounted the component. The
|
// Prop expressions belong to the parent that mounted the component. The
|
||||||
// server forwards these markers onto the rendered child root; evaluate
|
// server forwards these markers onto the rendered child root; evaluate
|
||||||
// them here and write changes into the child's prop signals.
|
// them here and write changes into the child's prop signals.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("*"))
|
||||||
.call(el.querySelectorAll("*"))
|
|
||||||
.filter(isScopeRoot)
|
.filter(isScopeRoot)
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
||||||
var binding;
|
var binding = pairBinding(attr.value);
|
||||||
try { binding = JSON.parse(attr.value); } catch (_) { return; }
|
if (!binding) return;
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
var propName = binding[0];
|
var propName = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
reactive(function () {
|
reactive(function () {
|
||||||
@@ -3017,8 +3094,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var host = root && root.querySelectorAll ? root : document;
|
var host = root && root.querySelectorAll ? root : document;
|
||||||
anchoredWriting = true;
|
anchoredWriting = true;
|
||||||
try {
|
try {
|
||||||
Array.prototype.slice
|
toArray(host.querySelectorAll(ANCHORED_SELECTOR))
|
||||||
.call(host.querySelectorAll(ANCHORED_SELECTOR))
|
|
||||||
.forEach(clampAnchored);
|
.forEach(clampAnchored);
|
||||||
} finally {
|
} finally {
|
||||||
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
||||||
@@ -3552,8 +3628,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// therefore no client-side binding to retain; consume its compiler markers
|
// therefore no client-side binding to retain; consume its compiler markers
|
||||||
// separately from component hydration.
|
// separately from component hydration.
|
||||||
if (host === document || host === document.documentElement) {
|
if (host === document || host === document.documentElement) {
|
||||||
Array.prototype.slice
|
toArray(document.documentElement.attributes)
|
||||||
.call(document.documentElement.attributes)
|
|
||||||
.forEach(function (attribute) {
|
.forEach(function (attribute) {
|
||||||
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
||||||
document.documentElement.removeAttribute(attribute.name);
|
document.documentElement.removeAttribute(attribute.name);
|
||||||
@@ -4195,7 +4270,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function emitPinInputEvent(root, name, extra) {
|
function emitPinInputEvent(root, name, extra) {
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var value = hidden ? hidden.value : "";
|
var value = hidden ? hidden.value : "";
|
||||||
var detail = {
|
var detail = {
|
||||||
component: "PinInput",
|
component: "PinInput",
|
||||||
@@ -4217,7 +4292,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
||||||
function setupPinInputController(root) {
|
function setupPinInputController(root) {
|
||||||
if (!root || root.__wrnexusPinInputController) return;
|
if (!root || root.__wrnexusPinInputController) return;
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var clearButton = root.querySelector("[data-pin-clear]");
|
var clearButton = root.querySelector("[data-pin-clear]");
|
||||||
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
||||||
@@ -4651,12 +4726,68 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse a while or for statement into its parts.
|
||||||
|
*
|
||||||
|
* Returns null for anything else so the caller falls through to the other
|
||||||
|
* statement forms. A for header is split on top-level semicolons only, so a
|
||||||
|
* semicolon inside a call argument or a string does not break it.
|
||||||
|
*/
|
||||||
|
function parseLoopStatement(source) {
|
||||||
|
source = String(source || "").trim();
|
||||||
|
|
||||||
|
var kind = null;
|
||||||
|
|
||||||
|
if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
|
||||||
|
kind = "while";
|
||||||
|
} else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
|
||||||
|
kind = "for";
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "(") return null;
|
||||||
|
|
||||||
|
var headerEnd = findClosingDelimiter(source, index, "(", ")");
|
||||||
|
|
||||||
|
if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
|
||||||
|
|
||||||
|
var header = source.slice(index + 1, headerEnd);
|
||||||
|
|
||||||
|
index = skipStatementWhitespace(source, headerEnd + 1);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
|
||||||
|
|
||||||
|
var bodyEnd = findClosingDelimiter(source, index, "{", "}");
|
||||||
|
|
||||||
|
if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
|
||||||
|
|
||||||
|
var body = source.slice(index + 1, bodyEnd);
|
||||||
|
|
||||||
|
if (kind === "while") {
|
||||||
|
return { init: null, condition: header.trim(), step: null, body: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = splitTopLevel(header, ";");
|
||||||
|
|
||||||
|
if (parts.length !== 3) throw new Error("A for header needs three parts");
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: parts[0].trim(),
|
||||||
|
condition: parts[1].trim(),
|
||||||
|
step: parts[2].trim(),
|
||||||
|
body: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
function runStatement(
|
function runStatement(
|
||||||
stmt,
|
stmt,
|
||||||
evalExpr,
|
evalExpr,
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
runBlock,
|
runBlock,
|
||||||
|
declare,
|
||||||
) {
|
) {
|
||||||
stmt = String(stmt || "").trim();
|
stmt = String(stmt || "").trim();
|
||||||
|
|
||||||
@@ -4707,6 +4838,60 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A loop body is author-written and runs in the browser, so a mistaken
|
||||||
|
* condition would freeze the tab. The cap keeps a runaway loop from
|
||||||
|
* hanging the page; it is far above any list a view renders.
|
||||||
|
*/
|
||||||
|
var loop = parseLoopStatement(stmt);
|
||||||
|
|
||||||
|
if (loop) {
|
||||||
|
var guard = 0;
|
||||||
|
|
||||||
|
if (loop.init) {
|
||||||
|
runStatement(loop.init, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!loop.condition || !!evalExpr(loop.condition)) {
|
||||||
|
if (++guard > 100000) break;
|
||||||
|
|
||||||
|
var outcome = runBlock(loop.body);
|
||||||
|
|
||||||
|
if (outcome && outcome.returned) return outcome;
|
||||||
|
|
||||||
|
if (loop.step) {
|
||||||
|
runStatement(loop.step, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A declaration binds a local, then falls through to the assignment
|
||||||
|
* branch below.
|
||||||
|
*
|
||||||
|
* Declaring first is what makes it local: an unknown name reaching
|
||||||
|
* writeScope becomes a signal and triggers a render sweep, so a var
|
||||||
|
* inside a shared function called during a render would loop forever.
|
||||||
|
* Once the name exists in locals, read and write both stay there.
|
||||||
|
*/
|
||||||
|
var declaration = stmt.match(
|
||||||
|
/^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (declaration) {
|
||||||
|
stmt = declaration[1].trim();
|
||||||
|
|
||||||
|
var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
|
||||||
|
|
||||||
|
if (declare) declare(declaredName, undefined);
|
||||||
|
|
||||||
|
if (!/=/.test(stmt)) {
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var increment = stmt.match(
|
var increment = stmt.match(
|
||||||
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
||||||
);
|
);
|
||||||
@@ -5699,7 +5884,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
||||||
if (element.__wrnDynamicMounted) return;
|
if (element.__wrnDynamicMounted) return;
|
||||||
element.__wrnDynamicMounted = true;
|
element.__wrnDynamicMounted = true;
|
||||||
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
|
var cases = toArray(element.children).filter(function (candidate) {
|
||||||
return candidate.hasAttribute("data-component-case");
|
return candidate.hasAttribute("data-component-case");
|
||||||
}).map(function (candidate) {
|
}).map(function (candidate) {
|
||||||
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterAll } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore globals a suite replaces, once the suite is done.
|
||||||
|
*
|
||||||
|
* These suites install a happy-dom window over the real globals and delete
|
||||||
|
* them before each test so every test starts clean. bun test loads and runs
|
||||||
|
* one file at a time rather than importing them all up front, so anything left
|
||||||
|
* deleted is still missing when the next suite runs -- which is how `bun test`
|
||||||
|
* with no argument came to fail unrelated files with "fetch is not a
|
||||||
|
* function". Names absent at capture time are deleted again rather than being
|
||||||
|
* restored as undefined, so a global that never existed does not gain a key.
|
||||||
|
*/
|
||||||
|
export function restoreGlobalsAfterAll(names: readonly string[]): void {
|
||||||
|
const captured = new Map<string, unknown>(
|
||||||
|
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const [name, value] of captured) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
} else {
|
||||||
|
(globalThis as Record<string, unknown>)[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
let win: any;
|
let win: any;
|
||||||
let fetchCalls: { url: string; opts: any }[];
|
let fetchCalls: { url: string; opts: any }[];
|
||||||
@@ -36,9 +37,7 @@ function install(bodyHtml: string): void {
|
|||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
beforeEach(() => {
|
const REPLACED_GLOBALS = [
|
||||||
const g = globalThis as any;
|
|
||||||
for (const k of [
|
|
||||||
"window",
|
"window",
|
||||||
"document",
|
"document",
|
||||||
"history",
|
"history",
|
||||||
@@ -47,7 +46,13 @@ beforeEach(() => {
|
|||||||
"CustomEvent",
|
"CustomEvent",
|
||||||
"Event",
|
"Event",
|
||||||
"fetch",
|
"fetch",
|
||||||
]) {
|
];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const g = globalThis as any;
|
||||||
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete g[k];
|
delete g[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
|
|||||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
||||||
import { mountHtml } from "@wrnexus/test";
|
import { mountHtml } from "@wrnexus/test";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
// Fresh DOM per test, with the runtime's globals bound.
|
// Fresh DOM per test, with the runtime's globals bound.
|
||||||
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
||||||
@@ -34,12 +35,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete (globalThis as Record<string, unknown>).window;
|
for (const name of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>).document;
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
delete (globalThis as Record<string, unknown>).location;
|
}
|
||||||
delete (globalThis as Record<string, unknown>).fetch;
|
|
||||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("split runtime hydrates a controller only from the controller asset", () => {
|
test("split runtime hydrates a controller only from the controller asset", () => {
|
||||||
@@ -117,6 +120,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
|
|||||||
expect(btn.textContent).toBe("2");
|
expect(btn.textContent).toBe("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compiled if blocks switch branches after hydration", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
|
||||||
|
{ cond: null, body: '<p class="closed">Closed</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="open: false, count: 2">` +
|
||||||
|
`<button data-on-click="open = !open">toggle</button>` +
|
||||||
|
`<button data-on-click="count++">increment</button>` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".closed")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compiled each blocks rerender rows and their empty branch", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "items",
|
||||||
|
item: "item",
|
||||||
|
index: "index",
|
||||||
|
body: '<p class="row">{index}:{item}</p>',
|
||||||
|
empty: '<p class="empty">Empty</p>',
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="items: ['a']">` +
|
||||||
|
`<button data-on-click="items = ['b', 'c']">more</button>` +
|
||||||
|
`<button data-on-click="items = []">clear</button>` +
|
||||||
|
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelectorAll("button")[0]!.click();
|
||||||
|
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
|
||||||
|
["0:b", "1:c"],
|
||||||
|
);
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".row")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
|
||||||
|
});
|
||||||
|
|
||||||
test("component functions support formatted multiline assignments and ternaries", () => {
|
test("component functions support formatted multiline assignments and ternaries", () => {
|
||||||
const behavior = Buffer.from(
|
const behavior = Buffer.from(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -1582,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
|
|||||||
);
|
);
|
||||||
expect(seen).toEqual([60]);
|
expect(seen).toEqual([60]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("control blocks created by a client rerender render their own content", () => {
|
||||||
|
// A nested block that arrives with the server HTML is hydrated: its first
|
||||||
|
// reactive pass must NOT redraw, or it would throw away server DOM. A nested
|
||||||
|
// block created later by an outer rerender has no server DOM, so skipping its
|
||||||
|
// first pass leaves it permanently empty — its dependencies never change
|
||||||
|
// again to trigger a second one.
|
||||||
|
const inner = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "g.rows.length > 0", body: '<p class="has-rows">HAS</p>' },
|
||||||
|
{ cond: null, body: '<p class="no-rows">NONE</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const outer = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "groups",
|
||||||
|
item: "g",
|
||||||
|
body:
|
||||||
|
`<section class="group"><span data-text="g.name">{g.name}</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
|
||||||
|
`</section>`,
|
||||||
|
empty: "",
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
|
||||||
|
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
|
||||||
|
`<template data-wrn-each="${outer}"></template>` +
|
||||||
|
`<section class="group"><span data-text="g.name">g1</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
|
||||||
|
`<template data-wrn-control-end></template></section>` +
|
||||||
|
`<template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
// The outer each rerendered: the new group's heading is present.
|
||||||
|
expect(win.document.querySelector(".group span")?.textContent).toBe("g2");
|
||||||
|
// The nested if inside that new row must have rendered its else branch.
|
||||||
|
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
|
||||||
|
expect(win.document.querySelector(".has-rows")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a for loop with a declaration initialiser runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="total: 0">` +
|
||||||
|
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
|
||||||
|
`<span data-text="total">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a while loop runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 1">` +
|
||||||
|
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
|
||||||
|
`<span data-text="n">1</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("16");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration stays local instead of becoming reactive state", () => {
|
||||||
|
// An unknown name reaching writeScope becomes a signal and triggers a render
|
||||||
|
// sweep. A var inside a function called during a render would then loop
|
||||||
|
// forever, so declarations must bind locally.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span class="out" data-text="out">0</span>` +
|
||||||
|
`<span class="leak" data-text="step"></span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".out")?.textContent).toBe("6");
|
||||||
|
expect(win.document.querySelector(".leak")?.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a control block removed from the DOM does not abort later renders", () => {
|
||||||
|
// Its effect stays in the renderers list. Running it against a detached node
|
||||||
|
// throws, which would abort the sweep and leave every later effect stale.
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
|
||||||
|
`<button data-on-click="n = n + 1">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const block = win.document.querySelector("[data-wrn-if]")!;
|
||||||
|
block.parentNode!.removeChild(block);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration statement assigns into scope", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span data-text="out">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unbounded loop stops instead of hanging the page", () => {
|
||||||
|
// Handler source is author-controlled and runs in the browser. Without a cap
|
||||||
|
// a mistaken condition freezes the tab with no way back.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
const value = Number(win.document.querySelector("span")?.textContent);
|
||||||
|
expect(value).toBeGreaterThan(0);
|
||||||
|
expect(Number.isFinite(value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a class binding inside data-for follows state the row never mentions", () => {
|
||||||
|
// The row's own array is untouched, so nothing rebuilds the list. The
|
||||||
|
// binding has to be reactive in its own right to keep up.
|
||||||
|
const binding = JSON.stringify(["is-active", "selected === row.id"]);
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="rows: [{"id":1},{"id":2}], selected: 1">` +
|
||||||
|
`<button data-on-click="selected = 2">pick</button>` +
|
||||||
|
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const items = () => Array.from(win.document.querySelectorAll("li"));
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(true);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(false);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(false);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||||
let sockets: FakeWS[];
|
let sockets: FakeWS[];
|
||||||
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>)[k];
|
delete (globalThis as Record<string, unknown>)[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.38",
|
"version": "0.8.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -470,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reserved inter-app RPC namespace is refused at the gateway edge, before
|
* Private inter-app RPC routes are refused at the gateway edge. The exact
|
||||||
* any proxying — it is only ever mounted by a child app's own dev-server and
|
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
|
||||||
* must never be reachable from outside the workspace.
|
* intentionally proxied to the selected child app.
|
||||||
*/
|
*/
|
||||||
export function isRpcGatewayPath(pathname: string): boolean {
|
export function isRpcGatewayPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === RPC_PATH_PREFIX ||
|
|
||||||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
||||||
pathname === RPC_STREAM_PATH_PREFIX ||
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
||||||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the trusted internal hop for a browser server-function request. */
|
||||||
|
export function gatewayBrowserRpcHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Headers {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
// The public request already passed the gateway's host and fetch-metadata
|
||||||
|
// checks. Present the internal proxy hop as same-origin to the child while
|
||||||
|
// retaining the double-submit CSRF cookie and header.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
headers.delete("host");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot every app as a child process, then route by Host on one gateway port. */
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
||||||
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
||||||
const port = opts.port ?? 3000;
|
const port = opts.port ?? 3000;
|
||||||
@@ -497,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
);
|
);
|
||||||
// Loopback-only origins, computed up front (ports are assigned by index
|
// Loopback-only origins, computed up front (ports are assigned by index
|
||||||
// before any child spawns) so every child can reach every other child
|
// before any child spawns) so every child can reach every other child
|
||||||
// directly — bypassing the gateway, which 404s the RPC prefix by design.
|
// directly — bypassing the gateway, which 404s private nested RPC routes.
|
||||||
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
||||||
@@ -737,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
||||||
const headers = stripUntrustedInternalHeaders(
|
const headers =
|
||||||
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
url.pathname === RPC_PATH_PREFIX
|
||||||
);
|
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
|
||||||
|
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
|
||||||
const body =
|
const body =
|
||||||
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ import {
|
|||||||
wrnBrowserArtifactUrlAsync,
|
wrnBrowserArtifactUrlAsync,
|
||||||
} from "./pipeline.ts";
|
} from "./pipeline.ts";
|
||||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||||
|
import { createRecycleMonitor } from "./recycle.ts";
|
||||||
|
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
|
||||||
|
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
|
||||||
|
* session reaches, and far less than what makes the server crawl. Set
|
||||||
|
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
|
||||||
|
*/
|
||||||
|
const RECYCLE_REBUILD_THRESHOLD = (() => {
|
||||||
|
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
|
||||||
|
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
|
||||||
|
})();
|
||||||
|
/** Quiet period required first, so a recycle never interrupts a live request. */
|
||||||
|
const RECYCLE_IDLE_MS = 10_000;
|
||||||
|
const RECYCLE_CHECK_MS = 5_000;
|
||||||
import { createHandlers, type WsData } from "./runtime.ts";
|
import { createHandlers, type WsData } from "./runtime.ts";
|
||||||
import { createDevAssetServer } from "./assets.ts";
|
import { createDevAssetServer } from "./assets.ts";
|
||||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||||
@@ -588,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
validateCsrf: validateRpcCsrf,
|
validateCsrf: validateRpcCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
|
||||||
|
* modules, so only dev needs to recycle.
|
||||||
|
*/
|
||||||
|
const recycle =
|
||||||
|
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
|
||||||
|
? createRecycleMonitor({
|
||||||
|
threshold: RECYCLE_REBUILD_THRESHOLD,
|
||||||
|
idleMs: RECYCLE_IDLE_MS,
|
||||||
|
onRecycle(reason) {
|
||||||
|
console.log(`[wrnexus] ${reason}`);
|
||||||
|
process.exit(RESTART_EXIT_CODE);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const server = Bun.serve<WsData>({
|
const server = Bun.serve<WsData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -595,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
maxRequestBodySize: 10 * 1024 * 1024,
|
maxRequestBodySize: 10 * 1024 * 1024,
|
||||||
...(opts.tls ? { tls: opts.tls } : {}),
|
...(opts.tls ? { tls: opts.tls } : {}),
|
||||||
fetch(request, server) {
|
fetch(request, server) {
|
||||||
|
recycle?.recordRequest(Date.now());
|
||||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||||
return handlers.fetch(request, server);
|
return handlers.fetch(request, server);
|
||||||
},
|
},
|
||||||
websocket: handlers.websocket,
|
websocket: handlers.websocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recycle) {
|
||||||
|
// unref so a pending check never keeps the process alive on its own.
|
||||||
|
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pluginRunner.hook("configureServer", {
|
await pluginRunner.hook("configureServer", {
|
||||||
server,
|
server,
|
||||||
@@ -626,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
||||||
|
recycle?.recordRebuild();
|
||||||
}
|
}
|
||||||
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
buildIslands,
|
buildIslands,
|
||||||
islandNamesFrom,
|
islandNamesFrom,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
type PageAst,
|
type PageAst,
|
||||||
type ViewNode,
|
type ViewNode,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
@@ -58,9 +59,28 @@ export function runMiddleware(
|
|||||||
*/
|
*/
|
||||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||||
const moduleVersions = new Map<string, number>();
|
const moduleVersions = new Map<string, number>();
|
||||||
|
/*
|
||||||
|
* Artifact URLs carry a content hash, so a rebuild registers a new key and the
|
||||||
|
* previous one is never requested again -- left alone these grow for the life
|
||||||
|
* of the dev server. Bounded rather than cleared on rebuild because a page
|
||||||
|
* already mid-load may still ask for the URL it was served.
|
||||||
|
*/
|
||||||
|
const ARTIFACT_PATH_LIMIT = 512;
|
||||||
const browserArtifactPaths = new Map<string, string>();
|
const browserArtifactPaths = new Map<string, string>();
|
||||||
const islandArtifactPaths = new Map<string, string>();
|
const islandArtifactPaths = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberArtifact(paths: Map<string, string>, pathname: string, artifact: string): void {
|
||||||
|
// Re-insert so a key still in use is treated as recent.
|
||||||
|
paths.delete(pathname);
|
||||||
|
paths.set(pathname, artifact);
|
||||||
|
|
||||||
|
while (paths.size > ARTIFACT_PATH_LIMIT) {
|
||||||
|
const oldest = paths.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
paths.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||||
interface CompileImportOptions {
|
interface CompileImportOptions {
|
||||||
mode: ImportMode;
|
mode: ImportMode;
|
||||||
@@ -611,6 +631,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
||||||
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
||||||
if (target === "browser") {
|
if (target === "browser") {
|
||||||
|
transformed = stripBrowserTypes(transformed);
|
||||||
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
||||||
}
|
}
|
||||||
writeFileSync(artifacts[target], transformed, "utf8");
|
writeFileSync(artifacts[target], transformed, "utf8");
|
||||||
@@ -622,7 +643,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
);
|
);
|
||||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
compileMetrics.compilations++;
|
compileMetrics.compilations++;
|
||||||
return artifacts;
|
return artifacts;
|
||||||
})().finally(() => asyncCompileInProgress.delete(key));
|
})().finally(() => asyncCompileInProgress.delete(key));
|
||||||
@@ -673,7 +694,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
.map(([, path]) => path);
|
.map(([, path]) => path);
|
||||||
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
||||||
compileMetrics.hits++;
|
compileMetrics.hits++;
|
||||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||||
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
||||||
// since, and after a restart with a warm cache nothing else would build them.
|
// since, and after a restart with a warm cache nothing else would build them.
|
||||||
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
||||||
@@ -712,10 +733,10 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.browser,
|
artifacts.browser,
|
||||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
stripBrowserTypes(rewriteArtifactImports(targets.browser, result.ast, file, "browser")),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.server,
|
artifacts.server,
|
||||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||||
@@ -775,7 +796,7 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
|||||||
|
|
||||||
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
||||||
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
||||||
islandArtifactPaths.set(pathname, artifact);
|
rememberArtifact(islandArtifactPaths, pathname, artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Recycle the dev server once hot rebuilds have piled up.
|
||||||
|
*
|
||||||
|
* Every rebuild of a `.wrn` file has to be given a new module identity,
|
||||||
|
* because Bun caches modules by path and would otherwise serve the old one.
|
||||||
|
* Bun has no API to unload a module, so each rebuild retains its predecessor
|
||||||
|
* for the life of the process -- measured at roughly 0.66 MB per rebuild,
|
||||||
|
* while edits that mint no new module (CSS) cost nothing. Over a long session
|
||||||
|
* that is the difference between a fast dev server and a stuck one.
|
||||||
|
*
|
||||||
|
* The process therefore recycles itself: the child exits with
|
||||||
|
* RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on
|
||||||
|
* their own because the HMR client already retries.
|
||||||
|
*
|
||||||
|
* Recycling is deferred until the server has been idle for a moment, so it
|
||||||
|
* never interrupts a request in flight. The cost is that in-memory state
|
||||||
|
* (realtime rooms, warmed caches) resets at that point, which is why the
|
||||||
|
* threshold is high enough that an ordinary editing session never reaches it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecycleMonitorOptions {
|
||||||
|
/** Retained rebuilds tolerated before a recycle is armed. */
|
||||||
|
threshold: number;
|
||||||
|
/** Quiet period required before recycling, in milliseconds. */
|
||||||
|
idleMs: number;
|
||||||
|
onRecycle: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleMonitor {
|
||||||
|
/** Count one rebuild that retained a module version. */
|
||||||
|
recordRebuild(): void;
|
||||||
|
/** Note that a request was served, at `now`. */
|
||||||
|
recordRequest(now: number): void;
|
||||||
|
/** Recycle if the threshold is passed and the server has gone quiet. */
|
||||||
|
tick(now: number): void;
|
||||||
|
retained(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor {
|
||||||
|
let rebuilds = 0;
|
||||||
|
let lastRequestAt: number | null = null;
|
||||||
|
let recycled = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordRebuild() {
|
||||||
|
rebuilds++;
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRequest(now: number) {
|
||||||
|
lastRequestAt = now;
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(now: number) {
|
||||||
|
if (recycled) return;
|
||||||
|
if (rebuilds < options.threshold) return;
|
||||||
|
// A server that has served nothing is idle by definition.
|
||||||
|
if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return;
|
||||||
|
|
||||||
|
recycled = true;
|
||||||
|
options.onRecycle(
|
||||||
|
`${rebuilds} hot rebuilds retained; restarting to release the memory they hold`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
retained() {
|
||||||
|
return rebuilds;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
defaultGatewayHostname,
|
defaultGatewayHostname,
|
||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
|
gatewayBrowserRpcHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
gatewayWebSocketBackendHeaders,
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
stripUntrustedInternalHeaders,
|
||||||
@@ -176,13 +177,40 @@ test("nested SSO proxy keeps the protected app's original request headers", () =
|
|||||||
expect(proxied.get("x-original-uri")).toBe("/settings");
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
|
test("the gateway proxies browser server functions but refuses private RPC routes", () => {
|
||||||
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
|
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false);
|
||||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("browser RPC proxy preserves CSRF credentials and trusts only the internal hop", () => {
|
||||||
|
const request = new Request(`http://web.localhost:3000${RPC_PATH_PREFIX}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "wrn-csrf=token",
|
||||||
|
"x-csrf-token": "token",
|
||||||
|
[RPC_INTERNAL_HEADER]: "forged",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayBrowserRpcHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.get("origin")).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.get("cookie")).toBe("wrn-csrf=token");
|
||||||
|
expect(headers.get("x-csrf-token")).toBe("token");
|
||||||
|
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
|
||||||
|
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
|
||||||
|
expect(headers.has("host")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
RPC_INTERNAL_HEADER,
|
RPC_INTERNAL_HEADER,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRecycleMonitor } from "../src/recycle.ts";
|
||||||
|
|
||||||
|
/** Fresh monitor with a small threshold so tests stay readable. */
|
||||||
|
function monitor(overrides: Partial<Parameters<typeof createRecycleMonitor>[0]> = {}) {
|
||||||
|
const recycled: string[] = [];
|
||||||
|
const control = createRecycleMonitor({
|
||||||
|
threshold: 3,
|
||||||
|
idleMs: 1000,
|
||||||
|
onRecycle: (reason) => recycled.push(reason),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { control, recycled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stays quiet below the rebuild threshold", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
control.tick(10_000);
|
||||||
|
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles once rebuilds pass the threshold and the server goes idle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(1_500);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for the idle gap rather than cutting off active work", () => {
|
||||||
|
// Recycling mid-request would drop it. The gap is the whole point.
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(500);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.recordRequest(900);
|
||||||
|
control.tick(1_400);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.tick(2_000);
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles only once even if it keeps being ticked", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(5_000);
|
||||||
|
control.tick(6_000);
|
||||||
|
control.tick(7_000);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server that never served a request can still recycle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.tick(9_999);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports how many rebuilds are being retained", () => {
|
||||||
|
const { control } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
|
||||||
|
expect(control.retained()).toBe(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*"
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlRegion {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte ranges of the markup inside each `view { }` block.
|
||||||
|
*
|
||||||
|
* This is a tolerant scanner rather than the parser: completion fires while
|
||||||
|
* the document is being typed, which is exactly when it does not parse.
|
||||||
|
*/
|
||||||
|
export function viewRegions(text: string): HtmlRegion[] {
|
||||||
|
const regions: HtmlRegion[] = [];
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const end = matchingBrace(text, bodyStart);
|
||||||
|
regions.push({ start: bodyStart, end });
|
||||||
|
pattern.lastIndex = end;
|
||||||
|
}
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset of the brace closing the block that starts at `from`, or the end of
|
||||||
|
* the text when it is never closed (an unterminated block is normal mid-edit).
|
||||||
|
*
|
||||||
|
* Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
|
||||||
|
* would otherwise open a string that never closes and swallow the rest of the
|
||||||
|
* file.
|
||||||
|
*/
|
||||||
|
function matchingBrace(text: string, from: number): number {
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote: string | null = null;
|
||||||
|
|
||||||
|
for (let index = from; index < text.length; index += 1) {
|
||||||
|
const char = text[index]!;
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parallel document containing only the markup.
|
||||||
|
*
|
||||||
|
* Everything outside a view block becomes whitespace of the same length, and
|
||||||
|
* newlines are preserved, so an offset in the source is the same offset here.
|
||||||
|
* That removes the need for a mapping table entirely.
|
||||||
|
*/
|
||||||
|
export function virtualHtmlDocument(document: TextDocument): {
|
||||||
|
uri: string;
|
||||||
|
languageId: "html";
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
const source = document.text;
|
||||||
|
const keep = new Array<boolean>(source.length).fill(false);
|
||||||
|
for (const region of viewRegions(source)) {
|
||||||
|
for (let index = region.start; index < region.end; index += 1) keep[index] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
|
const char = source[index]!;
|
||||||
|
text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: `${document.uri}.html`, languageId: "html", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsideHtml(document: TextDocument, offset: number): boolean {
|
||||||
|
return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regions for a document, cached by uri and version.
|
||||||
|
*
|
||||||
|
* A single keystroke produces a burst of completion, hover, and tag-close
|
||||||
|
* requests; without this each one rescans the file.
|
||||||
|
*/
|
||||||
|
const regionCache = new Map<string, { version: number; regions: HtmlRegion[] }>();
|
||||||
|
|
||||||
|
function regionsFor(document: TextDocument): HtmlRegion[] {
|
||||||
|
// Documents without a version have no way to signal changes, so bypass cache.
|
||||||
|
if (document.version === undefined) {
|
||||||
|
return viewRegions(document.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = regionCache.get(document.uri);
|
||||||
|
if (cached && cached.version === document.version) return cached.regions;
|
||||||
|
|
||||||
|
const regions = viewRegions(document.text);
|
||||||
|
regionCache.set(document.uri, { version: document.version, regions });
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a document's cached regions. Call when a document closes. */
|
||||||
|
export function clearHtmlRegionCache(uri?: string): void {
|
||||||
|
if (uri) regionCache.delete(uri);
|
||||||
|
else regionCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// The default `main` entrypoint is a UMD bundle whose internal AMD-style
|
||||||
|
// `require("./parser/htmlScanner")` calls survive bundling literally instead
|
||||||
|
// of being inlined, so a bundled language server fails at runtime with
|
||||||
|
// "Cannot find module './parser/htmlScanner'". The ESM entrypoint bundles
|
||||||
|
// cleanly, so import it explicitly.
|
||||||
|
import {
|
||||||
|
getLanguageService,
|
||||||
|
TextDocument as HtmlTextDocument,
|
||||||
|
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
||||||
|
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
||||||
|
import { offsetAt, type Position, type TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlCompletionItem {
|
||||||
|
label: string;
|
||||||
|
kind: number;
|
||||||
|
detail?: string;
|
||||||
|
documentation?: string;
|
||||||
|
sortText?: string;
|
||||||
|
insertText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getLanguageService();
|
||||||
|
|
||||||
|
/** The virtual document as the HTML service's own document type. */
|
||||||
|
function htmlDocument(document: TextDocument) {
|
||||||
|
const virtual = virtualHtmlDocument(document);
|
||||||
|
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdown(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") return value || undefined;
|
||||||
|
if (value && typeof value === "object" && "value" in value) {
|
||||||
|
return String((value as { value: unknown }).value) || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML completions for a position inside a view block.
|
||||||
|
*
|
||||||
|
* Every item carries the `1` sortText prefix so the server can rank WRNexus
|
||||||
|
* entries above these without filtering either list.
|
||||||
|
*/
|
||||||
|
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const parsed = service.parseHTMLDocument(virtual);
|
||||||
|
const list = service.doComplete(virtual, position, parsed);
|
||||||
|
|
||||||
|
return list.items.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
kind: typeof item.kind === "number" ? item.kind : 1,
|
||||||
|
detail: item.detail,
|
||||||
|
documentation: markdown(item.documentation),
|
||||||
|
sortText: `1${item.sortText ?? item.label}`,
|
||||||
|
insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const contents = markdown(result.contents);
|
||||||
|
return contents ? { contents } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlFoldingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
): Array<{ startLine: number; endLine: number }> {
|
||||||
|
return service
|
||||||
|
.getFoldingRanges(htmlDocument(document))
|
||||||
|
.map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranges of the opening and closing tag names, so renaming one renames both. */
|
||||||
|
export function htmlLinkedEditingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
position: Position,
|
||||||
|
): Array<{ start: Position; end: Position }> | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const ranges = service.findLinkedEditingRanges(
|
||||||
|
virtual,
|
||||||
|
position,
|
||||||
|
service.parseHTMLDocument(virtual),
|
||||||
|
);
|
||||||
|
return ranges && ranges.length ? ranges : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects `<Name attr="x" /` just before `position` and completes the `>`.
|
||||||
|
*
|
||||||
|
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
|
||||||
|
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
|
||||||
|
* start tag, since plain HTML has no such elements outside its fixed void-element
|
||||||
|
* list. WRNexus components (`<Card />`) are exactly that case, so we complete
|
||||||
|
* it ourselves rather than relying on the library.
|
||||||
|
*
|
||||||
|
* The scan tracks quote state from the tag's opening `<` up to `offset` (quotes
|
||||||
|
* are only meaningful inside a tag) so a `/` inside an attribute value — e.g. the
|
||||||
|
* first slash of `href="https://..."` — never misfires as a self-close: the
|
||||||
|
* library already returns `null` there on purpose, because the cursor sits in an
|
||||||
|
* attribute value, not a tag-close position.
|
||||||
|
*/
|
||||||
|
function selfClosingTagCompletion(text: string, offset: number): string | null {
|
||||||
|
if (text.charAt(offset - 1) !== "/") return null;
|
||||||
|
if (text.charAt(offset) === ">") return null;
|
||||||
|
|
||||||
|
const tagStart = text.lastIndexOf("<", offset - 1);
|
||||||
|
if (tagStart < 0) return null;
|
||||||
|
if (!/^<[A-Za-z][\w-]*/.test(text.slice(tagStart))) return null;
|
||||||
|
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = tagStart + 1; i < offset - 1; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "<" || ch === ">") return null;
|
||||||
|
}
|
||||||
|
if (quote) return null;
|
||||||
|
|
||||||
|
return ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snippet that closes the tag being typed, or null.
|
||||||
|
*
|
||||||
|
* Void elements and already-closed tags return null, which is why this decision
|
||||||
|
* belongs here rather than in the editor client.
|
||||||
|
*/
|
||||||
|
export function htmlTagComplete(document: TextDocument, position: Position): string | null {
|
||||||
|
const offset = offsetAt(document.text, position);
|
||||||
|
if (!isInsideHtml(document, offset)) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
return selfClosingTagCompletion(document.text, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One completion list from both sources.
|
||||||
|
*
|
||||||
|
* WRNexus entries take the `0` sortText prefix so they rank above HTML without
|
||||||
|
* either list being filtered. An exact label collision resolves to the
|
||||||
|
* WRNexus entry: a component named `Table` is what the author meant.
|
||||||
|
*/
|
||||||
|
interface CompletionLike {
|
||||||
|
label: string;
|
||||||
|
kind?: number;
|
||||||
|
sortText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCompletions(
|
||||||
|
wrnexus: CompletionLike[],
|
||||||
|
html: CompletionLike[],
|
||||||
|
): CompletionLike[] {
|
||||||
|
const taken = new Set(wrnexus.map((item) => item.label));
|
||||||
|
return [
|
||||||
|
...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })),
|
||||||
|
...html.filter((item) => !taken.has(item.label)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ import {
|
|||||||
htmlToWrn,
|
htmlToWrn,
|
||||||
type TextDocument,
|
type TextDocument,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
|
import {
|
||||||
|
htmlCompletions,
|
||||||
|
htmlFoldingRanges,
|
||||||
|
htmlHover,
|
||||||
|
htmlLinkedEditingRanges,
|
||||||
|
htmlTagComplete,
|
||||||
|
mergeCompletions,
|
||||||
|
} from "./html-service.ts";
|
||||||
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||||
|
|
||||||
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
||||||
const documents = new Map<string, TextDocument>();
|
const documents = new Map<string, TextDocument>();
|
||||||
@@ -111,8 +120,12 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: { openClose: true, change: 1, save: true },
|
textDocumentSync: { openClose: true, change: 1, save: true },
|
||||||
documentFormattingProvider: true,
|
documentFormattingProvider: true,
|
||||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
completionProvider: {
|
||||||
|
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
||||||
|
},
|
||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
|
foldingRangeProvider: true,
|
||||||
|
linkedEditingRangeProvider: true,
|
||||||
definitionProvider: true,
|
definitionProvider: true,
|
||||||
referencesProvider: true,
|
referencesProvider: true,
|
||||||
renameProvider: { prepareProvider: true },
|
renameProvider: { prepareProvider: true },
|
||||||
@@ -172,6 +185,7 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
case "textDocument/didClose":
|
case "textDocument/didClose":
|
||||||
clearDiagnosticTimer(params.textDocument.uri);
|
clearDiagnosticTimer(params.textDocument.uri);
|
||||||
documents.delete(params.textDocument.uri);
|
documents.delete(params.textDocument.uri);
|
||||||
|
clearHtmlRegionCache(params.textDocument.uri);
|
||||||
send({
|
send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "textDocument/publishDiagnostics",
|
method: "textDocument/publishDiagnostics",
|
||||||
@@ -195,9 +209,13 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/completion":
|
case "textDocument/completion": {
|
||||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
|
||||||
|
const html = document ? htmlCompletions(document, params.position) : [];
|
||||||
|
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "textDocument/documentSymbol": {
|
case "textDocument/documentSymbol": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? documentSymbols(document) : []);
|
result(message.id, document ? documentSymbols(document) : []);
|
||||||
@@ -210,7 +228,24 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
}
|
}
|
||||||
case "textDocument/hover": {
|
case "textDocument/hover": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? hover(document, params.position) : null);
|
const html = document ? htmlHover(document, params.position) : null;
|
||||||
|
result(message.id, html ?? (document ? hover(document, params.position) : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/foldingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlFoldingRanges(document) : []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/linkedEditingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
|
||||||
|
result(message.id, ranges ? { ranges } : null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "wrn/tagComplete": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlTagComplete(document, params.position) : null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/definition": {
|
case "textDocument/definition": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
page HtmlEditingCheck {
|
||||||
|
seo {
|
||||||
|
title = "HTML editing check"
|
||||||
|
description = "Scratch page for verifying editor support inside view blocks."
|
||||||
|
canonical = "/html-editing-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<h1>Editor check</h1>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { positionAt } from "../src/index.ts";
|
||||||
|
|
||||||
|
// End-to-end verification (Task 8): drives the real language server, over
|
||||||
|
// real LSP stdio framing, against a realistic WRN page fixture. The page's
|
||||||
|
// text is read from disk rather than duplicated here, so the fixture remains
|
||||||
|
// reusable and independently inspectable.
|
||||||
|
|
||||||
|
const pagePath = join(
|
||||||
|
fileURLToPath(new URL(".", import.meta.url)),
|
||||||
|
"fixtures/html-editing-check.wrn",
|
||||||
|
);
|
||||||
|
const pageText = readFileSync(pagePath, "utf8");
|
||||||
|
|
||||||
|
function packet(value: unknown): Uint8Array {
|
||||||
|
const body = JSON.stringify(value);
|
||||||
|
return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withServer<T>(
|
||||||
|
run: (
|
||||||
|
send: (msg: unknown) => Promise<void>,
|
||||||
|
readUntil: (marker: string) => Promise<string>,
|
||||||
|
) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<string> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
return output.slice(output.indexOf(marker));
|
||||||
|
}
|
||||||
|
async function send(message: unknown): Promise<void> {
|
||||||
|
process.stdin.write(packet(message));
|
||||||
|
await process.stdin.flush();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await run(send, readUntil);
|
||||||
|
} finally {
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("HTML editor support works end-to-end against the real scratch page", async () => {
|
||||||
|
const uri = "file:///html-editing-check.wrn";
|
||||||
|
|
||||||
|
// Positions derived from the real file content, not hardcoded line/column
|
||||||
|
// literals, so the test tracks the page if it changes.
|
||||||
|
const viewOpenBrace = pageText.indexOf("view {");
|
||||||
|
const viewCloseBrace = pageText.indexOf("}", pageText.indexOf("</main>"));
|
||||||
|
const h1TagOffset = pageText.indexOf("<h1>") + 1; // inside "h1"
|
||||||
|
const mainOpenNameOffset = pageText.indexOf("<main>") + 1; // inside "main" opening tag name
|
||||||
|
const mainOpenTagEndOffset = pageText.indexOf("<main>") + "<main>".length; // just after '>'
|
||||||
|
const seoTitleOffset = pageText.indexOf('title = "HTML editing check"'); // inside the seo block
|
||||||
|
|
||||||
|
// Simulate typing "<" inside the seo {} block: insert the character into a
|
||||||
|
// copy of the real page text (the checked-in file itself is never
|
||||||
|
// mutated) and ask for completion right after it. This is the scenario
|
||||||
|
// that actually exercises the view-region guard: if the guard were
|
||||||
|
// defeated, this exact position is where the HTML language service would
|
||||||
|
// offer tag completions, because it is positioned directly after an
|
||||||
|
// unclosed "<".
|
||||||
|
const seoInjectedText = pageText.slice(0, seoTitleOffset) + "<" + pageText.slice(seoTitleOffset);
|
||||||
|
const seoInjectedPosition = positionAt(seoInjectedText, seoTitleOffset + 1);
|
||||||
|
|
||||||
|
const viewPosition = positionAt(pageText, h1TagOffset);
|
||||||
|
const linkedEditingPosition = positionAt(pageText, mainOpenNameOffset);
|
||||||
|
const tagCompletePosition = positionAt(pageText, mainOpenTagEndOffset);
|
||||||
|
const viewStartLine = positionAt(pageText, viewOpenBrace).line;
|
||||||
|
const viewEndLine = positionAt(pageText, viewCloseBrace).line;
|
||||||
|
|
||||||
|
await withServer(async (send, readUntil) => {
|
||||||
|
await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: pageText } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Completion inside view {} : HTML entries present, WRNexus sorts first ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position: viewPosition },
|
||||||
|
});
|
||||||
|
const completionReply = await readUntil('"id":2');
|
||||||
|
// An HTML tag entry is present, tagged with the "1" (below-wrnexus) sortText prefix.
|
||||||
|
expect(completionReply).toMatch(/"label":"div"[^}]*"sortText":"1/);
|
||||||
|
// A WRNexus keyword entry is present, tagged with the "0" (above-html) sortText prefix.
|
||||||
|
expect(completionReply).toMatch(/"label":"page"[^}]*"sortText":"0/);
|
||||||
|
// No html-prefixed sortText is lexicographically smaller than any wrnexus one:
|
||||||
|
// every "0..." sortText must precede every "1..." sortText, which is exactly
|
||||||
|
// what makes WRNexus entries render above HTML ones in an editor.
|
||||||
|
expect(completionReply).not.toMatch(/"sortText":"1[^"]*"[\s\S]*"sortText":"0/);
|
||||||
|
|
||||||
|
// --- Negative case: completion right after "<" typed inside seo {} must NOT include HTML entries ---
|
||||||
|
const seoUri = "file:///html-editing-check-seo-inject.wrn";
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri: seoUri, version: 1, text: seoInjectedText } },
|
||||||
|
});
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri: seoUri }, position: seoInjectedPosition },
|
||||||
|
});
|
||||||
|
const seoReply = await readUntil('"id":3');
|
||||||
|
expect(seoReply).not.toContain('"label":"div"');
|
||||||
|
expect(seoReply).not.toContain('"label":"span"');
|
||||||
|
expect(seoReply).not.toContain('"label":"h1"');
|
||||||
|
expect(seoReply).not.toMatch(/"sortText":"1/);
|
||||||
|
|
||||||
|
// --- Hover over a tag inside the view block returns documentation ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 4,
|
||||||
|
method: "textDocument/hover",
|
||||||
|
params: { textDocument: { uri }, position: viewPosition },
|
||||||
|
});
|
||||||
|
const hoverReply = await readUntil('"id":4');
|
||||||
|
expect(hoverReply).toContain('"contents"');
|
||||||
|
expect(hoverReply).not.toMatch(/"result":\s*null/);
|
||||||
|
|
||||||
|
// --- Folding ranges are returned and all lie within the view block ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 5,
|
||||||
|
method: "textDocument/foldingRange",
|
||||||
|
params: { textDocument: { uri } },
|
||||||
|
});
|
||||||
|
const foldReply = await readUntil('"id":5');
|
||||||
|
expect(foldReply).toContain('"result":[');
|
||||||
|
const foldBody = foldReply.slice(foldReply.indexOf('"result":['));
|
||||||
|
const foldRanges = [...foldBody.matchAll(/"startLine":(\d+),"endLine":(\d+)/g)];
|
||||||
|
expect(foldRanges.length).toBeGreaterThan(0);
|
||||||
|
for (const [, start, end] of foldRanges) {
|
||||||
|
expect(Number(start)).toBeGreaterThanOrEqual(viewStartLine);
|
||||||
|
expect(Number(end)).toBeLessThanOrEqual(viewEndLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Linked editing at the <main> opening tag name returns two ranges ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 6,
|
||||||
|
method: "textDocument/linkedEditingRange",
|
||||||
|
params: { textDocument: { uri }, position: linkedEditingPosition },
|
||||||
|
});
|
||||||
|
const linkedReply = await readUntil('"id":6');
|
||||||
|
expect(linkedReply).toContain('"ranges"');
|
||||||
|
const rangeCount = (linkedReply.match(/"start":\{/g) ?? []).length;
|
||||||
|
expect(rangeCount).toBe(2);
|
||||||
|
|
||||||
|
// --- wrn/tagComplete after <main> returns the closing snippet ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 7,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri }, position: tagCompletePosition },
|
||||||
|
});
|
||||||
|
const tagCompleteReply = await readUntil('"id":7');
|
||||||
|
expect(tagCompleteReply).toContain('"result":"$0</main>"');
|
||||||
|
|
||||||
|
// --- wrn/tagComplete for a void element (<br>) returns null ---
|
||||||
|
const voidText = `page A {\n view {\n <br>\n }\n}\n`;
|
||||||
|
const voidUri = "file:///html-editing-check-void.wrn";
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri: voidUri, version: 1, text: voidText } },
|
||||||
|
});
|
||||||
|
const brOffset = voidText.indexOf("<br>") + "<br>".length;
|
||||||
|
const brPosition = positionAt(voidText, brOffset);
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 8,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri: voidUri }, position: brPosition },
|
||||||
|
});
|
||||||
|
const voidReply = await readUntil('"id":8');
|
||||||
|
expect(voidReply).toContain('"result":null');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
clearHtmlRegionCache,
|
||||||
|
isInsideHtml,
|
||||||
|
viewRegions,
|
||||||
|
virtualHtmlDocument,
|
||||||
|
} from "../src/html-regions.ts";
|
||||||
|
|
||||||
|
function doc(text: string): { uri: string; text: string; version?: number } {
|
||||||
|
return { uri: `file:///${Math.random()}.wrn`, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div class="card">hello</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("the virtual document preserves length and newline offsets", () => {
|
||||||
|
// This is what makes position mapping unnecessary. If it breaks, every
|
||||||
|
// feature reports positions off by some amount instead of failing loudly.
|
||||||
|
const source = doc(PAGE);
|
||||||
|
const virtual = virtualHtmlDocument(source);
|
||||||
|
|
||||||
|
expect(virtual.text.length).toBe(source.text.length);
|
||||||
|
expect(virtual.languageId).toBe("html");
|
||||||
|
for (let i = 0; i < source.text.length; i += 1) {
|
||||||
|
if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markup survives into the virtual document and everything else is blanked", () => {
|
||||||
|
const virtual = virtualHtmlDocument(doc(PAGE));
|
||||||
|
expect(virtual.text).toContain('<div class="card">hello</div>');
|
||||||
|
expect(virtual.text).not.toContain("page Home");
|
||||||
|
expect(virtual.text).not.toContain("view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an apostrophe in text content does not swallow later regions", () => {
|
||||||
|
// A scanner treating ' as a string delimiter anywhere considers the rest of
|
||||||
|
// the file one open string and loses every later region.
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<p>it's fine</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
component B {
|
||||||
|
view {
|
||||||
|
<span>second</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source)).toHaveLength(2);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text).toContain("<span>second</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolation braces nest without ending the region early", () => {
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<div class={cond ? "a" : "b"} data-x={{ a: 1 }}>after</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const regions = viewRegions(source);
|
||||||
|
expect(regions).toHaveLength(1);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text).toContain("after</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unparseable mid-edit markup still yields a region", () => {
|
||||||
|
// Completion fires exactly when the document does not parse.
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<div class="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file with no view block yields no regions and a fully blank document", () => {
|
||||||
|
const source = `page A {
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source)).toEqual([]);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text.trim()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regions are cached per document version", () => {
|
||||||
|
// One keystroke fans out into completion, hover, and tag-close requests.
|
||||||
|
const first = doc(PAGE);
|
||||||
|
first.version = 1;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
// Same version, mutated text: the cached regions are reused, proving the
|
||||||
|
// scan did not run again.
|
||||||
|
first.text = "page A { }";
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
first.version = 2;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearHtmlRegionCache drops a closed document's cached regions", () => {
|
||||||
|
// A reopened document commonly restarts at version 1. Without clearing the
|
||||||
|
// cache on close, that version would match the stale entry from the prior
|
||||||
|
// session and serve regions scanned from the old text.
|
||||||
|
const first = doc(PAGE);
|
||||||
|
first.version = 1;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
clearHtmlRegionCache(first.uri);
|
||||||
|
|
||||||
|
// Same uri, same version 1, but a document with no view block at all: if
|
||||||
|
// the cache had survived, this would still report true from the old scan.
|
||||||
|
const reopened = { uri: first.uri, text: "page A { }", version: 1 };
|
||||||
|
expect(isInsideHtml(reopened, PAGE.indexOf("<div"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isInsideHtml distinguishes markup from surrounding code", () => {
|
||||||
|
const source = doc(PAGE);
|
||||||
|
const markupOffset = PAGE.indexOf("<div");
|
||||||
|
const keywordOffset = PAGE.indexOf("page");
|
||||||
|
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(true);
|
||||||
|
expect(isInsideHtml(source, keywordOffset)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documents without a version field scan every time and detect mutations", () => {
|
||||||
|
// Without a version, the cache has no key to validate freshness. Mutations
|
||||||
|
// must be detected on every call, even when uri and document are reused.
|
||||||
|
const source = doc(PAGE);
|
||||||
|
// Explicitly verify version is undefined (not set by doc() helper).
|
||||||
|
expect(source.version).toBeUndefined();
|
||||||
|
|
||||||
|
const markupOffset = PAGE.indexOf("<div");
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(true);
|
||||||
|
|
||||||
|
// Mutate the text: remove the view block.
|
||||||
|
source.text = "page A { }";
|
||||||
|
// Same uri, same missing version, but different text: mutation must be detected.
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
htmlCompletions,
|
||||||
|
htmlFoldingRanges,
|
||||||
|
htmlHover,
|
||||||
|
htmlLinkedEditingRanges,
|
||||||
|
htmlTagComplete,
|
||||||
|
} from "../src/html-service.ts";
|
||||||
|
|
||||||
|
function doc(text: string) {
|
||||||
|
return { uri: "file:///Page.wrn", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOf(text: string, needle: string) {
|
||||||
|
const offset = text.indexOf(needle) + needle.length;
|
||||||
|
const before = text.slice(0, offset);
|
||||||
|
const lines = before.split("\n");
|
||||||
|
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("suggests HTML tags inside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const items = htmlCompletions(doc(text), positionOf(text, " <"));
|
||||||
|
expect(items.some((item) => item.label === "div")).toBe(true);
|
||||||
|
expect(items.every((item) => item.sortText?.startsWith("1"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suggests attributes inside a tag", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const items = htmlCompletions(doc(text), positionOf(text, "<input "));
|
||||||
|
expect(items.some((item) => item.label === "type")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nothing outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlCompletions(doc(text), positionOf(text, "function go() "))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hovers a tag inside a view block and nothing outside one", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlHover(doc(text), positionOf(text, "<di"))).not.toBeNull();
|
||||||
|
|
||||||
|
const code = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlHover(doc(code), positionOf(code, "func"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes an open tag and leaves void elements alone", () => {
|
||||||
|
const open = `page A {
|
||||||
|
view {
|
||||||
|
<div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(open), positionOf(open, "<div>"))).toContain("</div>");
|
||||||
|
|
||||||
|
const void_ = `page A {
|
||||||
|
view {
|
||||||
|
<br>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(void_), positionOf(void_, "<br>"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completes a self-closing component tag", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<Card /
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, "<Card /"))).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not misfire inside a quoted attribute value containing a slash", () => {
|
||||||
|
const doubleQuoted = `page A {
|
||||||
|
view {
|
||||||
|
<a href="https:/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(doubleQuoted), positionOf(doubleQuoted, `href="https:/`))).toBeNull();
|
||||||
|
|
||||||
|
const singleQuoted = `page A {
|
||||||
|
view {
|
||||||
|
<img src='/assets/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(singleQuoted), positionOf(singleQuoted, `src='/assets/`))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still completes a self-close after a preceding attribute", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<Card title="x" /
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, `title="x" /`))).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no tag completion outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folding ranges stay inside view regions", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<ul>
|
||||||
|
<li>one</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const ranges = htmlFoldingRanges(doc(text));
|
||||||
|
expect(ranges.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
|
||||||
|
for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
import { mergeCompletions } from "../src/html-service.ts";
|
||||||
|
|
||||||
|
test("merging ranks WRNexus entries above HTML and drops exact collisions", () => {
|
||||||
|
const merged = mergeCompletions(
|
||||||
|
[
|
||||||
|
{ label: "Card", kind: 7 },
|
||||||
|
{ label: "table", kind: 7 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "div", kind: 10, sortText: "1div" },
|
||||||
|
{ label: "table", kind: 10, sortText: "1table" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const labels = merged.map((item) => item.label);
|
||||||
|
expect(labels.filter((label) => label === "table")).toHaveLength(1);
|
||||||
|
expect(merged.find((item) => item.label === "Card")?.sortText?.startsWith("0")).toBe(true);
|
||||||
|
expect(merged.find((item) => item.label === "div")?.sortText?.startsWith("1")).toBe(true);
|
||||||
|
|
||||||
|
const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? ""));
|
||||||
|
expect(sorted[0]!.label).toBe("Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linked editing returns both the opening and closing tag names", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, "<di"));
|
||||||
|
expect(ranges).not.toBeNull();
|
||||||
|
expect(ranges).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linked editing returns null outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull();
|
||||||
|
});
|
||||||
@@ -217,3 +217,207 @@ test("coalesces rapid document changes into one pending diagnostic analysis", as
|
|||||||
process.kill();
|
process.kill();
|
||||||
await process.exited;
|
await process.exited;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("didClose drops the region cache so a reopened document at the same version is rescanned", async () => {
|
||||||
|
// Regression coverage for the didClose wiring in server.ts: without the
|
||||||
|
// clearHtmlRegionCache(uri) call there, a document that closes and reopens
|
||||||
|
// at version 1 (a common restart point) matches the stale cache entry from
|
||||||
|
// the prior session. Open first WITHOUT a view block (caching "no HTML
|
||||||
|
// here" for this uri/version), close, then reopen the SAME uri at the SAME
|
||||||
|
// version WITH a view block covering the same offset: correct behaviour
|
||||||
|
// rescans and finds it, a stale cache still says "no HTML here" and
|
||||||
|
// suppresses the completions entirely.
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///reopen.wrn";
|
||||||
|
const withoutView = `page A {
|
||||||
|
functions {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const withView = `page A {
|
||||||
|
view {
|
||||||
|
<
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const position = { line: 2, character: 5 };
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: withoutView } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const firstReply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(firstReply).not.toContain('"label":"div"');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({ jsonrpc: "2.0", method: "textDocument/didClose", params: { textDocument: { uri } } }),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: withView } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":3');
|
||||||
|
const secondReply = output.slice(output.indexOf('"id":3'));
|
||||||
|
expect(secondReply).toContain('"label":"div"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises and serves folding ranges and linked editing ranges over the wire", async () => {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///fold.wrn";
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
const initReply = output.slice(output.indexOf('"id":1'));
|
||||||
|
expect(initReply).toContain('"foldingRangeProvider":true');
|
||||||
|
expect(initReply).toContain('"linkedEditingRangeProvider":true');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/foldingRange",
|
||||||
|
params: { textDocument: { uri } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const foldReply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(foldReply).toContain('"result":[');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/linkedEditingRange",
|
||||||
|
params: { textDocument: { uri }, position: { line: 2, character: 6 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":3');
|
||||||
|
const linkedReply = output.slice(output.indexOf('"id":3'));
|
||||||
|
expect(linkedReply).toContain('"ranges"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves wrn/tagComplete over the wire", async () => {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///tag-complete.wrn";
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri }, position: { line: 2, character: 9 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const reply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(reply).toContain('"result":"$0</div>"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/react",
|
"name": "@wrnexus/react",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||||
|
|
||||||
export interface MountOptions {
|
export interface MountOptions {
|
||||||
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
|
/**
|
||||||
|
* Resolve an island module by name.
|
||||||
|
*
|
||||||
|
* `generation` counts remounts. A rebuilt island keeps its URL and the
|
||||||
|
* browser caches a module by URL, so a dev loader must fold this into the
|
||||||
|
* request or the page keeps running the code it first imported.
|
||||||
|
*/
|
||||||
|
loader: (name: string, generation: number) => Promise<{ default: ComponentType<any> }>;
|
||||||
development?: boolean;
|
development?: boolean;
|
||||||
/**
|
/**
|
||||||
* Re-render islands that are already mounted instead of skipping them.
|
* Re-render islands that are already mounted instead of skipping them.
|
||||||
@@ -16,6 +23,7 @@ export interface MountOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roots = new Map<Element, Root>();
|
const roots = new Map<Element, Root>();
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
export function islandRootCount(): number {
|
export function islandRootCount(): number {
|
||||||
return roots.size;
|
return roots.size;
|
||||||
@@ -32,8 +40,40 @@ function readProps(element: Element): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function whenReady(element: Element, strategy: string): Promise<void> {
|
function rectOf(element: Element): DOMRect | null {
|
||||||
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
const measure = (element as HTMLElement).getBoundingClientRect;
|
||||||
|
return typeof measure === "function" ? (element as HTMLElement).getBoundingClientRect() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inViewport(element: Element): boolean {
|
||||||
|
const rect = rectOf(element);
|
||||||
|
if (!rect) return false;
|
||||||
|
|
||||||
|
const height = window.innerHeight || document.documentElement?.clientHeight || 0;
|
||||||
|
const width = window.innerWidth || document.documentElement?.clientWidth || 0;
|
||||||
|
|
||||||
|
return rect.top <= height && rect.bottom >= 0 && rect.left <= width && rect.right >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve once the island's placeholder has come into view.
|
||||||
|
*
|
||||||
|
* An island renders nothing until it mounts, so its placeholder is usually
|
||||||
|
* zero-height -- and IntersectionObserver does not treat a zero-area target
|
||||||
|
* consistently. When it declines to report one, the island never mounts at
|
||||||
|
* all, which is silent: the markup and every asset are present and correct.
|
||||||
|
* Those are driven from the element's own rect instead; a placeholder with
|
||||||
|
* real size (an SSR fallback, or a reserved min-height) still uses the
|
||||||
|
* observer, which is cheaper and needs no scroll listener.
|
||||||
|
*/
|
||||||
|
function whenVisible(element: Element): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return Promise.resolve();
|
||||||
|
if (inViewport(element)) return Promise.resolve();
|
||||||
|
|
||||||
|
const rect = rectOf(element);
|
||||||
|
const hasArea = !!rect && rect.width > 0 && rect.height > 0;
|
||||||
|
|
||||||
|
if (hasArea && typeof IntersectionObserver !== "undefined") {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting)) {
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
@@ -44,6 +84,26 @@ function whenReady(element: Element, strategy: string): Promise<void> {
|
|||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
if (!inViewport(element)) return;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener("scroll", check, true);
|
||||||
|
window.removeEventListener("resize", check);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture phase so a scrolling container, not just the page, wakes it.
|
||||||
|
window.addEventListener("scroll", check, true);
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function whenReady(element: Element, strategy: string): Promise<void> {
|
||||||
|
if (strategy === "visible") return whenVisible(element);
|
||||||
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
||||||
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
||||||
}
|
}
|
||||||
@@ -65,7 +125,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
|
|||||||
|
|
||||||
let Component: ComponentType<any>;
|
let Component: ComponentType<any>;
|
||||||
try {
|
try {
|
||||||
Component = (await options.loader(name)).default;
|
Component = (await options.loader(name, generation)).default;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
||||||
return;
|
return;
|
||||||
@@ -118,6 +178,10 @@ export function unmountIslands(root: ParentNode): void {
|
|||||||
* a runtime and is out of scope.
|
* a runtime and is out of scope.
|
||||||
*/
|
*/
|
||||||
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||||
|
// A remount only happens after a rebuild, so the modules on the other side
|
||||||
|
// of the loader have changed.
|
||||||
|
generation++;
|
||||||
|
|
||||||
// Every mounted container is swapped for a bare clone before remounting.
|
// Every mounted container is swapped for a bare clone before remounting.
|
||||||
//
|
//
|
||||||
// Re-rendering the existing root is not enough: HMR wipes the container's
|
// Re-rendering the existing root is not enough: HMR wipes the container's
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
export function getIslandRuntime(development = false): string {
|
export function getIslandRuntime(development = false): string {
|
||||||
return `
|
return `
|
||||||
(function () {
|
(function () {
|
||||||
function loader(name) {
|
function loader(name, generation) {
|
||||||
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
|
var url = "/__wrnexus/island/" + encodeURIComponent(name) + ".js";
|
||||||
|
// A rebuilt island keeps its URL, and the browser caches modules by URL,
|
||||||
|
// so a remount has to ask for a URL it has not imported before.
|
||||||
|
return import(generation ? url + "?v=" + generation : url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
|||||||
@@ -92,3 +92,26 @@ test("remount re-renders in place instead of creating a second root", async () =
|
|||||||
expect(islandRootCount()).toBe(1);
|
expect(islandRootCount()).toBe(1);
|
||||||
expect(window.document.body.textContent).toContain("v1");
|
expect(window.document.body.textContent).toContain("v1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a remount asks the loader for a newer generation than the mount did", async () => {
|
||||||
|
// The rebuilt island keeps its URL. Without a changing generation the dev
|
||||||
|
// loader re-imports the cached module and the page keeps the old code --
|
||||||
|
// silently, because the island still mounts and still works.
|
||||||
|
const window = domWith(marker);
|
||||||
|
const generations: number[] = [];
|
||||||
|
const loader = async (_name: string, generation: number) => {
|
||||||
|
generations.push(generation);
|
||||||
|
return { default: () => createElement("span", null, `gen ${generation}`) };
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await remountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generations.length).toBe(2);
|
||||||
|
expect(generations[1]).toBeGreaterThan(generations[0]!);
|
||||||
|
expect(window.document.body.textContent).toContain(`gen ${generations[1]}`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,3 +128,91 @@ test("malformed props JSON falls back to empty props instead of throwing", async
|
|||||||
expect(window.document.body.textContent).toContain("none");
|
expect(window.document.body.textContent).toContain("none");
|
||||||
expect(islandRootCount()).toBe(1);
|
expect(islandRootCount()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand in for the browser's IntersectionObserver, reporting only targets that
|
||||||
|
* actually have area.
|
||||||
|
*
|
||||||
|
* That is the case the real one is inconsistent about: an island placeholder
|
||||||
|
* is empty until it mounts, so it is zero-height, and an engine that declines
|
||||||
|
* to report it leaves the island unmounted forever.
|
||||||
|
*/
|
||||||
|
function installAreaOnlyObserver(window: Window, onObserve?: () => void) {
|
||||||
|
const observed: Element[] = [];
|
||||||
|
(globalThis as any).IntersectionObserver = class {
|
||||||
|
constructor(private callback: (entries: { isIntersecting: boolean }[]) => void) {}
|
||||||
|
observe(element: Element) {
|
||||||
|
observed.push(element);
|
||||||
|
onObserve?.();
|
||||||
|
const rect = (element as unknown as HTMLElement).getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) this.callback([{ isIntersecting: true }]);
|
||||||
|
}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
return observed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place the island marker at a given position with a given size. */
|
||||||
|
function positionIsland(window: Window, top: number, height: number) {
|
||||||
|
const element = window.document.querySelector("[data-wrn-island]") as unknown as HTMLElement;
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({ top, bottom: top + height, left: 0, right: 800, width: 800, height }) as DOMRect;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounts a visible island whose placeholder has no height", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
installAreaOnlyObserver(window);
|
||||||
|
positionIsland(window, 40, 0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a visible island below the fold waits, then mounts once scrolled to", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
installAreaOnlyObserver(window);
|
||||||
|
positionIsland(window, 5000, 0);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
// Started outside act: it stays pending until the scroll, and the render it
|
||||||
|
// then performs is what act needs to wrap.
|
||||||
|
const mounting = mountIslands(host(window), { loader }).then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(islandRootCount()).toBe(0);
|
||||||
|
|
||||||
|
positionIsland(window, 100, 0);
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new window.Event("scroll"));
|
||||||
|
await mounting;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a placeholder with real size still goes through the observer", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
let observedCount = 0;
|
||||||
|
installAreaOnlyObserver(window, () => {
|
||||||
|
observedCount++;
|
||||||
|
});
|
||||||
|
positionIsland(window, 5000, 300);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observedCount).toBe(1);
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ function parseOriginMap(value: string | undefined): Record<string, string> {
|
|||||||
*
|
*
|
||||||
* Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each
|
* Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each
|
||||||
* child before spawning it) over `appOrigin`, which resolves the app's
|
* child before spawning it) over `appOrigin`, which resolves the app's
|
||||||
* PUBLIC origin. The public origin is the wrong target for RPC: the gateway
|
* PUBLIC origin. The public origin is the wrong target for inter-app RPC: the
|
||||||
* unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that
|
* gateway 404s private `/__wrnexus/rpc/<service>/<procedure>` routes. (The
|
||||||
* arrives at a public origin — that block is the whole point, it is what
|
* exact prefix remains the CSRF-protected browser server-function endpoint.)
|
||||||
* keeps inter-app calls off the public internet. Falling back to `appOrigin`
|
* That block keeps inter-app calls off the public internet. Falling back to `appOrigin`
|
||||||
* when no internal-origin map is present keeps single-app and test setups
|
* when no internal-origin map is present keeps single-app and test setups
|
||||||
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ describe("RPC integration", () => {
|
|||||||
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
||||||
try {
|
try {
|
||||||
// The workspace (public) origin deliberately points somewhere that
|
// The workspace (public) origin deliberately points somewhere that
|
||||||
// cannot serve the RPC — the gateway 404s the RPC prefix on any
|
// cannot serve the RPC — the gateway 404s private nested RPC routes
|
||||||
// request that arrives at a public origin. Only the internal-origin
|
// that arrive at a public origin. Only the internal-origin
|
||||||
// map points at the real server. If httpTransport() ever falls back
|
// map points at the real server. If httpTransport() ever falls back
|
||||||
// to the public origin by default again, this call fails.
|
// to the public origin by default again, this call fails.
|
||||||
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1654,11 +1654,21 @@ test("carousel supports RTL, multiple slides, dragging, snap, and thumbnail layo
|
|||||||
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
|
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("carousel autoplay timers are available in the browser reactive runtime", async () => {
|
test("carousel autoplay timers are available in the browser reactive runtime", () => {
|
||||||
const { getReactiveRuntime } = await import("../../csr/src/index.ts");
|
// Resolve them through the runtime rather than asserting on its source: the
|
||||||
const runtime = getReactiveRuntime();
|
// timers only have to be reachable from a client expression, and a substring
|
||||||
expect(runtime).toContain('name === "setInterval"');
|
// check goes stale the moment the lookup is written differently.
|
||||||
expect(runtime).toContain('name === "clearInterval"');
|
const dom = mountHtml(
|
||||||
|
`<div data-scope="started: 0, stopped: 0">` +
|
||||||
|
`<button data-on-click="started = setInterval; stopped = clearInterval">go</button>` +
|
||||||
|
`<span class="started" data-text="started"></span>` +
|
||||||
|
`<span class="stopped" data-text="stopped"></span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
(dom.querySelector("button") as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(dom.querySelector(".started")?.textContent).toContain("function");
|
||||||
|
expect(dom.querySelector(".stopped")?.textContent).toContain("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
|
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
|
||||||
|
|||||||
@@ -169,7 +169,19 @@ addCheck(
|
|||||||
* minified transfer once. That is the number worth defending.
|
* minified transfer once. That is the number worth defending.
|
||||||
*/
|
*/
|
||||||
const runtimeBudgets = {
|
const runtimeBudgets = {
|
||||||
"reactive-runtime.ts": 49_000,
|
/*
|
||||||
|
* Raised from 49,000 on 2026-08-19, to just above what the runtime actually
|
||||||
|
* minifies to rather than to a round number with room to drift.
|
||||||
|
*
|
||||||
|
* The runtime was already over 49,000 before client-side control blocks and
|
||||||
|
* for/while support were added. Trimming it afterwards -- prototype-safe
|
||||||
|
* global lookup tables, shared hasOwn/toArray/pairBinding helpers, dead code
|
||||||
|
* -- recovered 2,414 bytes, which was everything available without dropping
|
||||||
|
* or deferring a feature. What a visitor pays is the compressed transfer:
|
||||||
|
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
|
||||||
|
* cache.
|
||||||
|
*/
|
||||||
|
"reactive-runtime.ts": 50_500,
|
||||||
"component-controllers.ts": 24_100,
|
"component-controllers.ts": 24_100,
|
||||||
"nav-runtime.ts": 12_000,
|
"nav-runtime.ts": 12_000,
|
||||||
"realtime-runtime.ts": 8_000,
|
"realtime-runtime.ts": 8_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user