Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f075df42c | ||
|
|
281615a4b0 | ||
|
|
3ae5d7cf97 | ||
|
|
b5029889a5 | ||
|
|
e9db4ca24d | ||
|
|
5318320c70 | ||
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 | ||
|
|
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 | ||
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d |
@@ -25,3 +25,6 @@ tsconfig.focus.json
|
|||||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||||
# which requires the scaffold to live inside the repo tree).
|
# which requires the scaffold to live inside the repo tree).
|
||||||
**/test/.tmp-*/
|
**/test/.tmp-*/
|
||||||
|
|
||||||
|
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ bun.lockb
|
|||||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||||
**/*.gen.ts
|
**/*.gen.ts
|
||||||
**/*.generated.d.ts
|
**/*.generated.d.ts
|
||||||
|
**/*.generated.api-checks.ts
|
||||||
|
|
||||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||||
editors/vscode/src/compiler.cjs
|
editors/vscode/src/compiler.cjs
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
|
||||||
|
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
|
||||||
|
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
|
||||||
|
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
|
||||||
|
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
|
||||||
|
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
|
||||||
|
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
|
||||||
|
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
|
||||||
|
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
|
||||||
|
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
|
||||||
|
required boolean field given `false` still errors, as before (checkbox-required semantics
|
||||||
|
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
|
||||||
|
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
|
||||||
|
to start failing.
|
||||||
|
|
||||||
|
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
|
||||||
|
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
|
||||||
|
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
|
||||||
|
when no input is passed explicitly. Previously such endpoints silently validated
|
||||||
|
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
|
||||||
|
what was sent. **Behavior change for downstream apps:** a request that previously passed
|
||||||
|
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
|
||||||
|
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
|
||||||
|
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
|
||||||
|
priority over reading the request.
|
||||||
|
|
||||||
## 0.8.8
|
## 0.8.8
|
||||||
|
|
||||||
- Added the framework request context to `.wrn` language-server type environments.
|
- Added the framework request context to `.wrn` language-server type environments.
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.41",
|
"version": "0.8.46",
|
||||||
"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.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
@@ -318,18 +318,18 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@wrnexus/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.37",
|
"version": "0.8.41",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-toolbar": {
|
"packages/dev-toolbar": {
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.12",
|
"version": "0.8.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "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:*",
|
||||||
},
|
},
|
||||||
@@ -621,7 +622,7 @@
|
|||||||
},
|
},
|
||||||
"packages/syntax": {
|
"packages/syntax": {
|
||||||
"name": "@wrnexus/syntax",
|
"name": "@wrnexus/syntax",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
},
|
},
|
||||||
"packages/test": {
|
"packages/test": {
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
@@ -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:*",
|
||||||
},
|
},
|
||||||
@@ -662,7 +663,7 @@
|
|||||||
},
|
},
|
||||||
"packages/validation": {
|
"packages/validation": {
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "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.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1040,7 +1040,8 @@
|
|||||||
"rpcManifest",
|
"rpcManifest",
|
||||||
"runtimeCapabilities",
|
"runtimeCapabilities",
|
||||||
"runtimeTypeOf",
|
"runtimeTypeOf",
|
||||||
"serializeIslandProps"
|
"serializeIslandProps",
|
||||||
|
"stripBrowserTypes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/content": {
|
"@wrnexus/content": {
|
||||||
@@ -1674,6 +1675,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1712,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
@@ -2851,7 +2854,10 @@
|
|||||||
"LexError",
|
"LexError",
|
||||||
"Lexer",
|
"Lexer",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenType"
|
"TokenType",
|
||||||
|
"isIdentPart",
|
||||||
|
"isIdentStart",
|
||||||
|
"skipLiteralOrComment"
|
||||||
],
|
],
|
||||||
"./types": [
|
"./types": [
|
||||||
"RuntimeType",
|
"RuntimeType",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
# HTML editing support for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-18
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
|
||||||
|
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
|
||||||
|
tag-level folding.
|
||||||
|
|
||||||
|
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html` → `html`), which is
|
||||||
|
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
|
||||||
|
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **HTML formatting.** See "Formatting is deliberately excluded" below.
|
||||||
|
- Editor support outside VS Code beyond what standard LSP gives for free.
|
||||||
|
- Changing `.wrn` syntax or the compiler.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
|
||||||
|
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
|
||||||
|
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
|
||||||
|
| Region detection | Tolerant scanner over a virtual document, not the AST |
|
||||||
|
| HTML knowledge | `vscode-html-languageservice` |
|
||||||
|
| Formatting | Excluded — `formatWrn` already owns markup formatting |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Virtual HTML document
|
||||||
|
|
||||||
|
New module: `packages/language-server/src/html-regions.ts`, exporting
|
||||||
|
`virtualHtmlDocument(document)`.
|
||||||
|
|
||||||
|
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
|
||||||
|
newlines preserved. The virtual document therefore has the same size and the same line/column
|
||||||
|
geometry as the source, so a position in the source _is_ the position in the virtual document.
|
||||||
|
No mapping table and no translation layer.
|
||||||
|
|
||||||
|
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
|
||||||
|
compacts code and carries line mappings back to source. Compaction is necessary there because
|
||||||
|
the output must be valid TypeScript. HTML has no such requirement, so the simpler
|
||||||
|
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
|
||||||
|
does not arise.
|
||||||
|
|
||||||
|
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
|
||||||
|
newlines at identical offsets. If this breaks, every feature reports positions off by some
|
||||||
|
amount rather than failing loudly.
|
||||||
|
|
||||||
|
### Region detection
|
||||||
|
|
||||||
|
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
|
||||||
|
while the document is being typed, which is exactly when it does not parse. The scanner finds
|
||||||
|
`view` followed by `{` and tracks brace depth to the matching close.
|
||||||
|
|
||||||
|
Two hazards it must handle, both of which defeat a naive implementation:
|
||||||
|
|
||||||
|
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
|
||||||
|
delimiter anywhere will consider the rest of the file one open string and lose every later
|
||||||
|
region. Quotes are tracked only inside attribute values, never in text nodes.
|
||||||
|
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
|
||||||
|
depth must be counted rather than scanning for the next `}`.
|
||||||
|
|
||||||
|
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
|
||||||
|
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
|
||||||
|
|
||||||
|
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
|
||||||
|
costs a single scan.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
### The server becomes the single authority inside view blocks
|
||||||
|
|
||||||
|
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
|
||||||
|
virtual document is non-blank there, which costs one character lookup.
|
||||||
|
|
||||||
|
**Inside a view block**, one list is assembled from two sources:
|
||||||
|
|
||||||
|
| Source | `sortText` prefix | Content |
|
||||||
|
| ------- | ----------------- | ------------------------------------------------------------------------ |
|
||||||
|
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
|
||||||
|
| HTML | `1` | Tags, attributes, attribute values |
|
||||||
|
|
||||||
|
`sortText` drives ordering independently of the label, so components rank above HTML tags
|
||||||
|
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
|
||||||
|
plus workspace items.
|
||||||
|
|
||||||
|
The server already indexes components, props, outputs, and slots
|
||||||
|
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
|
||||||
|
of the merge are already available to it.
|
||||||
|
|
||||||
|
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
|
||||||
|
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
|
||||||
|
resolves to the component.
|
||||||
|
|
||||||
|
### Trigger characters
|
||||||
|
|
||||||
|
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
|
||||||
|
`" "`, `"="`, `"\""`, and `"/"`.
|
||||||
|
|
||||||
|
### This fixes an existing bug
|
||||||
|
|
||||||
|
The extension's `completion.js` registers its own provider with `<` among its trigger
|
||||||
|
characters, and the language server answers `textDocument/completion` as well. VS Code
|
||||||
|
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
|
||||||
|
involved at all.
|
||||||
|
|
||||||
|
As part of this work the extension's provider returns nothing when the position is inside a view
|
||||||
|
block, and keeps its current behaviour elsewhere. One owner per context.
|
||||||
|
|
||||||
|
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
|
||||||
|
context, so future component-intelligence work belongs in the server rather than in
|
||||||
|
`completion.js`.
|
||||||
|
|
||||||
|
## Hover
|
||||||
|
|
||||||
|
`textDocument/hover` answers from the HTML service over the virtual document when the position
|
||||||
|
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
|
||||||
|
region, existing hover behaviour is unchanged.
|
||||||
|
|
||||||
|
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
|
||||||
|
any HTML entry of the same name, matching the completion precedence rule above.
|
||||||
|
|
||||||
|
## Tag handling
|
||||||
|
|
||||||
|
### Linked editing is standard LSP
|
||||||
|
|
||||||
|
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
|
||||||
|
it lives in the shared server like everything else.
|
||||||
|
|
||||||
|
### Auto-close on type is the one client-side piece
|
||||||
|
|
||||||
|
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
|
||||||
|
client-side, and this follows the same shape:
|
||||||
|
|
||||||
|
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
|
||||||
|
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
|
||||||
|
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
|
||||||
|
snippet or `null`.
|
||||||
|
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
|
||||||
|
|
||||||
|
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
|
||||||
|
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
|
||||||
|
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
|
||||||
|
|
||||||
|
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
|
||||||
|
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
|
||||||
|
|
||||||
|
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
|
||||||
|
`wrnexus.*` naming.
|
||||||
|
|
||||||
|
### Emmet
|
||||||
|
|
||||||
|
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
|
||||||
|
|
||||||
|
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
|
||||||
|
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
|
||||||
|
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
|
||||||
|
edge is real.
|
||||||
|
|
||||||
|
## Folding
|
||||||
|
|
||||||
|
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
|
||||||
|
the virtual document, filtered to view regions.
|
||||||
|
|
||||||
|
Today folding comes only from `language-configuration.json` markers, which work at block level
|
||||||
|
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
|
||||||
|
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
|
||||||
|
to work unchanged and tag folding appears inside markup.
|
||||||
|
|
||||||
|
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
|
||||||
|
outside a view region would let a fold swallow a brace boundary.
|
||||||
|
|
||||||
|
## Formatting is deliberately excluded
|
||||||
|
|
||||||
|
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
|
||||||
|
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
|
||||||
|
`printWidth`. It is a markup formatter that understands WRNexus syntax.
|
||||||
|
|
||||||
|
Adding HTML formatting would do two harmful things:
|
||||||
|
|
||||||
|
- **Two formatters would fight.** Output would depend on which ran last.
|
||||||
|
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
|
||||||
|
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
|
||||||
|
|
||||||
|
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
|
||||||
|
`editors/vscode`.
|
||||||
|
|
||||||
|
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
|
||||||
|
and passes other `require`s through to Node, so the package must be resolvable at runtime from
|
||||||
|
the extension. `editors/vscode` currently ships exactly one runtime dependency
|
||||||
|
(`vscode-languageclient`); this adds the second.
|
||||||
|
|
||||||
|
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
|
||||||
|
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Region scanner (`packages/language-server/test/`)
|
||||||
|
|
||||||
|
- **The invariant**, property-style across fixtures: virtual text length equals source length and
|
||||||
|
newlines sit at identical offsets.
|
||||||
|
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
|
||||||
|
found.
|
||||||
|
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
|
||||||
|
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
|
||||||
|
completion, not an edge case.
|
||||||
|
- **Multiple view blocks**, and files with none.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
|
||||||
|
- Outside a view block: response identical to current behaviour — the guard proving non-markup
|
||||||
|
contexts are undisturbed.
|
||||||
|
- Collision: a component named `Table` yields one entry, the component.
|
||||||
|
|
||||||
|
### Tag handling
|
||||||
|
|
||||||
|
- `<div>` → `</div>`; `<br>` → nothing; `<Card /` → `/>`; outside a view region → `null`.
|
||||||
|
- Linked editing returns ranges covering both the opening and closing tag names.
|
||||||
|
|
||||||
|
### Hover
|
||||||
|
|
||||||
|
- Inside a view region, a known tag returns HTML documentation.
|
||||||
|
- A component name returns the component detail, not an HTML entry of the same name.
|
||||||
|
|
||||||
|
### Folding
|
||||||
|
|
||||||
|
- Every returned range lies inside a view region.
|
||||||
|
- Block-level marker folding still works.
|
||||||
|
|
||||||
|
### Toolchain guards
|
||||||
|
|
||||||
|
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
|
||||||
|
- Manifest assertion that `emmet.includeLanguages` maps `wrn` → `html`, alongside the existing
|
||||||
|
marketplace checks in `editors/vscode/test`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- HTML formatting — see above; improve `formatWrn` instead.
|
||||||
|
- Moving the remaining `completion.js` component intelligence into the server. This design only
|
||||||
|
requires it to stand down inside view blocks; relocating the rest is follow-up work.
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Typed, callable `api` blocks for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and
|
||||||
|
handles failure — callable on demand from client code.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Calling this application's own API routes from a `.wrn` page should be declarative and
|
||||||
|
type-checked. Today it is neither: the `api` block takes no parameters at all, so anything
|
||||||
|
carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly,
|
||||||
|
JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site.
|
||||||
|
|
||||||
|
### What the current block cannot do
|
||||||
|
|
||||||
|
These are implementation facts, not gaps in documentation:
|
||||||
|
|
||||||
|
- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path
|
||||||
|
containing `?` or `#`.
|
||||||
|
- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}`
|
||||||
|
ends the path at the brace and the remainder is parsed as the block body.
|
||||||
|
- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no
|
||||||
|
parameter a payload could occupy, whatever method is named.
|
||||||
|
- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later
|
||||||
|
pass, so a binding cannot be re-run.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving
|
||||||
|
the existing `isSafeApiPath` guarantee.
|
||||||
|
- Replacing `server function`. That remains the way to run arbitrary server logic over RPC.
|
||||||
|
- Author-settable headers. See "Why `headers` is excluded".
|
||||||
|
- Parameterised server-render fetching. See "The SSR boundary".
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time |
|
||||||
|
| Targets | This app's `/api/*` routes only |
|
||||||
|
| Execution | Decided by the enclosing mode, not a modifier |
|
||||||
|
| Request values | Declared fields, supplied at the call site |
|
||||||
|
| Type source | Route contract when available, declared types otherwise (with a warning) |
|
||||||
|
| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.api-checks.ts` |
|
||||||
|
| Failure | `error {}` converts a failure to a value; without it, the call rejects |
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
client {
|
||||||
|
api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
designation?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace
|
||||||
|
joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way
|
||||||
|
as `server.searchUsers()`.
|
||||||
|
|
||||||
|
`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at
|
||||||
|
call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without
|
||||||
|
relaxing it.
|
||||||
|
|
||||||
|
### Backward compatibility
|
||||||
|
|
||||||
|
A bare body keeps meaning "this is the response block", unchanged:
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users/ssr {
|
||||||
|
return users.map((user) => user.name).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule is **bare body = legacy untyped block; sections = typed block.**
|
||||||
|
|
||||||
|
The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic.
|
||||||
|
The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves.
|
||||||
|
**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is
|
||||||
|
impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so
|
||||||
|
`tsc` can check `data.users` against the route's contract.
|
||||||
|
|
||||||
|
### Why `headers` is excluded
|
||||||
|
|
||||||
|
Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow
|
||||||
|
from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an
|
||||||
|
author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a
|
||||||
|
concrete case.
|
||||||
|
|
||||||
|
## Type safety
|
||||||
|
|
||||||
|
### The constraint that shapes this
|
||||||
|
|
||||||
|
`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and
|
||||||
|
`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling
|
||||||
|
the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing.
|
||||||
|
|
||||||
|
What _is_ type-checked is application source under `app/`. Enforcement goes there.
|
||||||
|
|
||||||
|
**Corrected 2026-08-19, during implementation.** This section originally placed the assertions in
|
||||||
|
`app/types/wrnexus.generated.d.ts`. That is inert: the root `tsconfig.json` sets
|
||||||
|
`skipLibCheck: true`, which exempts the _contents_ of every `.d.ts`, so an assertion written there
|
||||||
|
can never raise a `tsc` error. Proven by forcing `skipLibCheck: false`, under which the same
|
||||||
|
assertion fires as `TS2344`. The reasoning was right and the file was wrong. Per-block assertions
|
||||||
|
are emitted into a real `.ts` file instead — `app/types/wrnexus.generated.api-checks.ts` — which
|
||||||
|
`skipLibCheck` does not exempt and which `include: ["app"]` compiles. The helper types stay in the
|
||||||
|
`.d.ts`, where being declarations is correct.
|
||||||
|
|
||||||
|
### Three pieces
|
||||||
|
|
||||||
|
**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`,
|
||||||
|
`ApiContract`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AssertAssignable<Actual, Expected> = Actual extends Expected ? true : never;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Per-block assertions**, generated into `app/types/wrnexus.generated.api-checks.ts`. `wrnexus generate types` already parses
|
||||||
|
`.wrn` sources to build the route list, so it can read each block's declared fields and emit:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type __wrn_check_searchUsers = AssertAssignable<
|
||||||
|
{ name?: string; age?: number },
|
||||||
|
ApiInput<"/api/users", "POST">
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so
|
||||||
|
`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type
|
||||||
|
comparison inside the WRNexus compiler, and no need to type-check build output. The language server
|
||||||
|
already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline.
|
||||||
|
|
||||||
|
**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch,
|
||||||
|
JSON handling, and failure branch live in one tested place instead of being re-emitted per block.
|
||||||
|
|
||||||
|
### Routes without a contract
|
||||||
|
|
||||||
|
A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves
|
||||||
|
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
|
||||||
|
the route. Untyped endpoints stay visible rather than silently passing.
|
||||||
|
|
||||||
|
### GET parameters travel as strings
|
||||||
|
|
||||||
|
A `GET` block's `parameters` become a query string (see Request assembly below), and every
|
||||||
|
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
|
||||||
|
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
|
||||||
|
only because the endpoint's own schema coerces it back: `checkField` in
|
||||||
|
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
|
||||||
|
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
|
||||||
|
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
|
||||||
|
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
|
||||||
|
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
|
||||||
|
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
|
||||||
|
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
|
||||||
|
route also has no contract for the generator to check against, so it already falls under "Routes
|
||||||
|
without a contract" above and is flagged there.
|
||||||
|
|
||||||
|
### Staleness
|
||||||
|
|
||||||
|
Checking is only as current as the generated file, so this stays wired into the existing
|
||||||
|
`check:generated-types` gate, which already verifies those artifacts match their sources.
|
||||||
|
|
||||||
|
## Compilation and runtime
|
||||||
|
|
||||||
|
### Client mode
|
||||||
|
|
||||||
|
Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser
|
||||||
|
module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to
|
||||||
|
client scope exactly as `server` is today.
|
||||||
|
|
||||||
|
Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and
|
||||||
|
codegen drops them before emit. A client function body that carried TypeScript into a `.mjs`
|
||||||
|
artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same
|
||||||
|
discipline applies here.
|
||||||
|
|
||||||
|
`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time
|
||||||
|
binding needs no rework.
|
||||||
|
|
||||||
|
### Request assembly
|
||||||
|
|
||||||
|
`callApi` builds the request:
|
||||||
|
|
||||||
|
- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which
|
||||||
|
removes the `if (filter.trim())` ladder authors write by hand.
|
||||||
|
- **Everything else** — a JSON body with `content-type: application/json`.
|
||||||
|
- Always `credentials: "same-origin"` and `accept: application/json`.
|
||||||
|
- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the
|
||||||
|
`wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221`
|
||||||
|
for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a
|
||||||
|
correctness gain rather than only less typing.
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
**`error {}` converts a failure into a value; without it, the call rejects.**
|
||||||
|
|
||||||
|
- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the
|
||||||
|
call's result. With no `response` block, `data` is returned unchanged.
|
||||||
|
- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and
|
||||||
|
`data` in scope. `return []` yields an empty list and no exception.
|
||||||
|
- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working.
|
||||||
|
|
||||||
|
A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class
|
||||||
|
this design is most concerned with, so the absence of an `error` block means throw, never swallow.
|
||||||
|
|
||||||
|
### The SSR boundary
|
||||||
|
|
||||||
|
`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at
|
||||||
|
render time to supply arguments, and inferring an implicit source — page state, query parameters —
|
||||||
|
would be a guess. Parameterised requests are a client-mode feature; parameterised server-side
|
||||||
|
fetching stays with `server function`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Parser (`packages/syntax/test/`)
|
||||||
|
|
||||||
|
- A sectioned block parses into `request`/`response`/`error` parts.
|
||||||
|
- A bare body still parses as the response block (the backward-compatibility guarantee).
|
||||||
|
- `request` inside an `ssr {}` block is a parse error naming the restriction.
|
||||||
|
- A malformed section reports the offending offset rather than failing later in codegen.
|
||||||
|
|
||||||
|
### Type generation (`packages/cli/test/` or the types generator's suite)
|
||||||
|
|
||||||
|
- A block targeting a `defineEndpoint` route emits an assertion referencing that contract.
|
||||||
|
- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc`
|
||||||
|
over a fixture, not by string-matching the generated file.
|
||||||
|
- A block targeting a contract-less route emits the warning and falls back to declared types.
|
||||||
|
- `check:generated-types` still passes with blocks present.
|
||||||
|
|
||||||
|
### Codegen (`packages/compiler/test/`)
|
||||||
|
|
||||||
|
- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives
|
||||||
|
into the browser module (the guard for the `55fed217` defect class).
|
||||||
|
- Declared types do not appear in the emitted module.
|
||||||
|
- An `ssr` block's output is unchanged from today for a bare body.
|
||||||
|
|
||||||
|
### Runtime (`packages/csr/test/`)
|
||||||
|
|
||||||
|
- GET omits `undefined` parameters and includes the rest.
|
||||||
|
- Non-GET attaches `x-csrf-token` from cookie and from meta.
|
||||||
|
- 2xx runs `response`; its return value is the result.
|
||||||
|
- Non-2xx runs `error`; its return value is the result.
|
||||||
|
- With no `error` block, a non-2xx rejects rather than resolving to `undefined`.
|
||||||
|
|
||||||
|
### End to end (`examples/basic-app`)
|
||||||
|
|
||||||
|
A page calling a typed block against a real route, driven in a browser: the request carries the
|
||||||
|
declared fields, the response block's value reaches page state, and a deliberately failing call
|
||||||
|
takes the `error` path. Tests that pass while the feature does not work have been a recurring
|
||||||
|
failure in this repository, so browser verification is part of the definition of done.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- External and third-party API targets, with the allowlist and credential handling they require.
|
||||||
|
- Author-settable request headers.
|
||||||
|
- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is.
|
||||||
|
- Parameterised server-render fetching.
|
||||||
|
- Response caching and request de-duplication.
|
||||||
@@ -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 };
|
||||||
+445
-47
@@ -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: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
|
||||||
// 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";
|
||||||
@@ -483,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"payload",
|
||||||
]);
|
]);
|
||||||
@@ -683,6 +712,27 @@ function _functionEntry(ast, fn, availableFunctions) {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Client-mode api blocks become members of an `api` object in client scope.
|
||||||
|
*
|
||||||
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
|
*/
|
||||||
|
function apiBindings(ast) {
|
||||||
|
const members = ast.dataApis
|
||||||
|
.filter((block) => block.mode === "client" && block.sections)
|
||||||
|
.map((block) => {
|
||||||
|
const sections = block.sections;
|
||||||
|
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
|
||||||
|
const error = (0, syntax_1.eraseFunctionTypes)(sections.error).trim();
|
||||||
|
const failure = error
|
||||||
|
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||||
|
: `(error) => { throw error; }`;
|
||||||
|
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
|
||||||
|
});
|
||||||
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||||
|
}
|
||||||
function generateBrowserModule(ast) {
|
function generateBrowserModule(ast) {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||||
const functionNames = functions.map((fn) => fn.name);
|
const functionNames = functions.map((fn) => fn.name);
|
||||||
@@ -690,10 +740,19 @@ function generateBrowserModule(ast) {
|
|||||||
const selectedImports = selectedBrowserImports(ast, functions);
|
const selectedImports = selectedBrowserImports(ast, functions);
|
||||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||||
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
|
// `api` is only defined as a client-scope binding when the page actually has
|
||||||
|
// client-mode api blocks (see apiBindings below). A page that declares
|
||||||
|
// `state api` without any client api blocks must keep reading/writing that
|
||||||
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
|
// there is a real `api` binding to shadow it.
|
||||||
|
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||||
|
const localRuntimeBindings = hasClientApi
|
||||||
|
? RUNTIME_BINDINGS
|
||||||
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||||
|
const sharedState = state.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name));
|
||||||
const sharedProps = ast.props
|
const sharedProps = ast.props
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name));
|
.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name));
|
||||||
const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
|
const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
|
||||||
!RUNTIME_BINDINGS.has(name) &&
|
!RUNTIME_BINDINGS.has(name) &&
|
||||||
!sharedState.includes(name) &&
|
!sharedState.includes(name) &&
|
||||||
@@ -730,6 +789,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
@@ -1261,7 +1321,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
|
||||||
@@ -1447,6 +1521,7 @@ function renderBinding(binding) {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function hasClientBehavior(nodes) {
|
function hasClientBehavior(nodes) {
|
||||||
@@ -1503,18 +1578,28 @@ function apiBindingMap(ast, sharedHelpers) {
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
function ssrRuntimeSource() {
|
function ssrRuntimeSource() {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -1533,6 +1618,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
|
|||||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -1562,18 +1659,53 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -2004,13 +2136,16 @@ function generateInner(ast) {
|
|||||||
continue;
|
continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
||||||
continue;
|
continue;
|
||||||
loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`);
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
|
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
${storeDeclarations}
|
${storeDeclarations}
|
||||||
@@ -2425,6 +2560,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 +2641,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 +2657,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 +2669,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 +3460,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 +3482,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; } });
|
||||||
@@ -4500,6 +4720,151 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||||
__exportStar(require("@wrnexus/syntax/types"), exports);
|
__exportStar(require("@wrnexus/syntax/types"), exports);
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.parseApiSections = parseApiSections;
|
||||||
|
exports.hasRequestSection = hasRequestSection;
|
||||||
|
/**
|
||||||
|
* Parse the sectioned form of an `api` block body.
|
||||||
|
*
|
||||||
|
* Returns null when no section keyword is present, which is how the legacy
|
||||||
|
* bare-body form stays valid: the caller keeps treating the body as the
|
||||||
|
* response expression.
|
||||||
|
*
|
||||||
|
* Detection and slicing both drive the tokenizer's own string/comment-aware
|
||||||
|
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
|
||||||
|
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
|
||||||
|
* mentioned in a comment can't be mistaken for a real section.
|
||||||
|
*/
|
||||||
|
const tokenizer_ts_1 = require("./tokenizer.js");
|
||||||
|
const SECTION_NAMES = ["request", "response", "error"];
|
||||||
|
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
|
||||||
|
/**
|
||||||
|
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
|
||||||
|
* one of `names`. Strings, template literals, and comments are skipped via
|
||||||
|
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
|
||||||
|
* keyword mentioned inside a string or comment, or nested inside an unrelated
|
||||||
|
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
|
||||||
|
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
|
||||||
|
* not a reimplementation of it.
|
||||||
|
*/
|
||||||
|
function scanTopLevelBlocks(source, names) {
|
||||||
|
const found = new Map();
|
||||||
|
const lx = new tokenizer_ts_1.Lexer(source);
|
||||||
|
let depth = 0;
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
while (i < source.length) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === "\n") {
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
|
atLineStart = false;
|
||||||
|
if (depth === 0 && (0, tokenizer_ts_1.isIdentStart)(c)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < source.length && (0, tokenizer_ts_1.isIdentPart)(source[j]))
|
||||||
|
j++;
|
||||||
|
const word = source.slice(i, j);
|
||||||
|
// Skip trivia between the identifier and a possible '{' without
|
||||||
|
// treating anything in between as significant yet.
|
||||||
|
let k = j;
|
||||||
|
let lineStartAtK = false;
|
||||||
|
while (k < source.length) {
|
||||||
|
const kc = source[k];
|
||||||
|
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kc === "\n") {
|
||||||
|
lineStartAtK = true;
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kSkipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, k, lineStartAtK);
|
||||||
|
if (kSkipped !== null) {
|
||||||
|
k = kSkipped;
|
||||||
|
lineStartAtK = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (names.includes(word) && source[k] === "{") {
|
||||||
|
lx.pos = k;
|
||||||
|
const start = k + 1;
|
||||||
|
const text = lx.readBalancedBraces();
|
||||||
|
if (!found.has(word))
|
||||||
|
found.set(word, { text, start });
|
||||||
|
i = lx.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "{")
|
||||||
|
depth++;
|
||||||
|
else if (c === "}")
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
/** Rebase a span captured from `outer.text` back onto the original source. */
|
||||||
|
function absolutize(span, outer) {
|
||||||
|
if (!span)
|
||||||
|
return undefined;
|
||||||
|
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||||
|
}
|
||||||
|
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||||
|
function parseFields(span) {
|
||||||
|
if (!span)
|
||||||
|
return [];
|
||||||
|
const fields = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const rawLine of span.text.split("\n")) {
|
||||||
|
const lineOffset = span.start + cursor;
|
||||||
|
cursor += rawLine.length + 1;
|
||||||
|
const line = rawLine.trim().replace(/,$/, "");
|
||||||
|
if (!line || line.startsWith("//"))
|
||||||
|
continue;
|
||||||
|
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
throw new tokenizer_ts_1.LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`);
|
||||||
|
}
|
||||||
|
fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
function parseApiSections(source) {
|
||||||
|
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||||
|
if (top.size === 0)
|
||||||
|
return null;
|
||||||
|
const request = top.get("request");
|
||||||
|
const sub = request
|
||||||
|
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
|
||||||
|
: new Map();
|
||||||
|
return {
|
||||||
|
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||||
|
body: parseFields(absolutize(sub.get("body"), request)),
|
||||||
|
response: top.get("response")?.text ?? "",
|
||||||
|
error: top.get("error")?.text ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** True when the block declares a `request` section. */
|
||||||
|
function hasRequestSection(source) {
|
||||||
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -5848,6 +6213,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0;
|
|||||||
exports.parse = parse;
|
exports.parse = parse;
|
||||||
exports.parseHtmlView = parseHtmlView;
|
exports.parseHtmlView = parseHtmlView;
|
||||||
const spec_ts_1 = require("./spec.js");
|
const spec_ts_1 = require("./spec.js");
|
||||||
|
const api_sections_ts_1 = require("./api-sections.js");
|
||||||
/**
|
/**
|
||||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
*
|
*
|
||||||
@@ -6320,7 +6686,18 @@ function parse(source) {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name, method, path, body });
|
const sections = (0, api_sections_ts_1.parseApiSections)(body);
|
||||||
|
if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) {
|
||||||
|
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
|
||||||
|
}
|
||||||
|
dataApis.push({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: sections ? "" : body,
|
||||||
|
...(sections ? { sections } : {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
@@ -6955,13 +7332,55 @@ exports.WRN_DIAGNOSTIC_CODES = {
|
|||||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.Lexer = exports.LexError = void 0;
|
exports.Lexer = exports.isIdentPart = exports.isIdentStart = exports.LexError = void 0;
|
||||||
|
exports.skipLiteralOrComment = skipLiteralOrComment;
|
||||||
class LexError extends Error {
|
class LexError extends Error {
|
||||||
}
|
}
|
||||||
exports.LexError = LexError;
|
exports.LexError = LexError;
|
||||||
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||||
|
exports.isIdentStart = isIdentStart;
|
||||||
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
||||||
|
exports.isIdentPart = isIdentPart;
|
||||||
|
/**
|
||||||
|
* Skip over a string/template literal or comment starting at `src[i]`, using
|
||||||
|
* the exact rules `readBalancedBraces` needs to stay comment- and
|
||||||
|
* string-aware: `/* block *\/` comments anywhere, `//` line comments only at
|
||||||
|
* the start of a line (so a bare `https://…` in view text isn't mistaken for
|
||||||
|
* one), and `"`, `'`, `` ` `` strings with backslash escapes.
|
||||||
|
*
|
||||||
|
* Returns the index just past what it skipped, or `null` when `src[i]` isn't
|
||||||
|
* the start of one of those. Exported so any other raw-body scanner that
|
||||||
|
* needs to walk `.wrn` source without tripping over strings or comments
|
||||||
|
* (e.g. the `api` section scanner) shares this logic instead of
|
||||||
|
* reimplementing it — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces.
|
||||||
|
*/
|
||||||
|
function skipLiteralOrComment(src, i, atLineStart) {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === "/" && src[i + 1] === "*") {
|
||||||
|
const close = src.indexOf("*/", i + 2);
|
||||||
|
return close === -1 ? src.length : close + 2;
|
||||||
|
}
|
||||||
|
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||||
|
const newline = src.indexOf("\n", i + 2);
|
||||||
|
return newline === -1 ? src.length : newline;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length) {
|
||||||
|
if (src[j] === "\\") {
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (src[j] === c)
|
||||||
|
return j + 1;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return src.length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
class Lexer {
|
class Lexer {
|
||||||
src;
|
src;
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -7030,9 +7449,9 @@ class Lexer {
|
|||||||
case "'":
|
case "'":
|
||||||
return this.readString(c, pos);
|
return this.readString(c, pos);
|
||||||
}
|
}
|
||||||
if (isIdentStart(c)) {
|
if ((0, exports.isIdentStart)(c)) {
|
||||||
let v = "";
|
let v = "";
|
||||||
while (this.pos < src.length && isIdentPart(src[this.pos]))
|
while (this.pos < src.length && (0, exports.isIdentPart)(src[this.pos]))
|
||||||
v += src[this.pos++];
|
v += src[this.pos++];
|
||||||
return { type: "ident", value: v, pos };
|
return { type: "ident", value: v, pos };
|
||||||
}
|
}
|
||||||
@@ -7252,45 +7671,23 @@ class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str = null;
|
|
||||||
/** True while only whitespace has been seen since the last newline. */
|
/** True while only whitespace has been seen since the last newline. */
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (; i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i];
|
const c = src[i];
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str)
|
|
||||||
str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "\n") {
|
if (c === "\n") {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1)
|
i = skipped;
|
||||||
break; // unterminated: fall through to the error
|
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
|
||||||
const newline = src.indexOf("\n", i + 2);
|
|
||||||
if (newline === -1)
|
|
||||||
break;
|
|
||||||
i = newline - 1; // let the loop's own increment land on the newline
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c !== " " && c !== "\t" && c !== "\r")
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{")
|
if (c === "{")
|
||||||
depth++;
|
depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
@@ -7300,6 +7697,7 @@ class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
|
||||||
// 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 };
|
||||||
|
|||||||
+22675
-34
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,30 +2,24 @@
|
|||||||
|
|
||||||
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) {
|
Diagnostic: class Diagnostic {
|
||||||
if (request === "vscode") {
|
constructor(range, message, severity) {
|
||||||
return {
|
this.range = range;
|
||||||
Diagnostic: class Diagnostic {
|
this.message = message;
|
||||||
constructor(range, message, severity) {
|
this.severity = severity;
|
||||||
this.range = range;
|
}
|
||||||
this.message = message;
|
},
|
||||||
this.severity = severity;
|
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||||
}
|
Range: class Range {
|
||||||
},
|
constructor(start, end) {
|
||||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
this.start = start;
|
||||||
Range: class Range {
|
this.end = end;
|
||||||
constructor(start, end) {
|
}
|
||||||
this.start = start;
|
},
|
||||||
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 };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineEndpoint } from "@wrnexus/core";
|
||||||
|
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
|
||||||
|
|
||||||
|
interface DirectoryUser {
|
||||||
|
name: string;
|
||||||
|
designation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL: DirectoryUser[] = [
|
||||||
|
{ name: "Ajay", designation: "UI" },
|
||||||
|
{ name: "Asha", designation: "Backend" },
|
||||||
|
{ name: "Chen", designation: "UI" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-typed handler: input resolves to `{ name?: string }` from
|
||||||
|
* SearchDirectorySchema, so the block's request shape is checked for real
|
||||||
|
* (not against `unknown`, which the untyped-handler shape resolved to).
|
||||||
|
*/
|
||||||
|
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
|
||||||
|
input: SearchDirectorySchema,
|
||||||
|
description: "Case-insensitive substring search over the demo directory by name.",
|
||||||
|
handler(input) {
|
||||||
|
const needle = String(input.name ?? "").toLowerCase();
|
||||||
|
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||||
import type { Db, ExecResult } from "@wrnexus/db";
|
import type { Db, ExecResult } from "@wrnexus/db";
|
||||||
import { users } from "./schema.ts";
|
import { users } from "./schema.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
page ApiBlockDemo {
|
||||||
|
state nameFilter = "a"
|
||||||
|
state found = ""
|
||||||
|
state failed = ""
|
||||||
|
|
||||||
|
client {
|
||||||
|
api searchDirectory POST /api/directory {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function search(): Promise<void> {
|
||||||
|
const users = await api.searchDirectory({ name: nameFilter })
|
||||||
|
found = users.map((user) => user.name).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<button @click="search()">Search</button>
|
||||||
|
<p class="found" data-text="found">{found}</p>
|
||||||
|
<p class="failed" data-text="failed">{failed}</p>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
export interface Routes {
|
export interface Routes {
|
||||||
"/": Record<string, never>;
|
"/": Record<string, never>;
|
||||||
"/about": Record<string, never>;
|
"/about": Record<string, never>;
|
||||||
|
"/api-block-demo": Record<string, never>;
|
||||||
"/async-data": Record<string, never>;
|
"/async-data": Record<string, never>;
|
||||||
"/chat": Record<string, never>;
|
"/chat": Record<string, never>;
|
||||||
"/client-only": Record<string, never>;
|
"/client-only": Record<string, never>;
|
||||||
@@ -27,6 +28,7 @@ export interface Routes {
|
|||||||
export interface RouteNames {
|
export interface RouteNames {
|
||||||
"index": "/";
|
"index": "/";
|
||||||
"about": "/about";
|
"about": "/about";
|
||||||
|
"api.block.demo": "/api-block-demo";
|
||||||
"async.data": "/async-data";
|
"async.data": "/async-data";
|
||||||
"chat": "/chat";
|
"chat": "/chat";
|
||||||
"client.only": "/client-only";
|
"client.only": "/client-only";
|
||||||
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
|
|||||||
const paths: Record<RouteName, RoutePath> = {
|
const paths: Record<RouteName, RoutePath> = {
|
||||||
"index": "/",
|
"index": "/",
|
||||||
"about": "/about",
|
"about": "/about",
|
||||||
|
"api.block.demo": "/api-block-demo",
|
||||||
"async.data": "/async-data",
|
"async.data": "/async-data",
|
||||||
"chat": "/chat",
|
"chat": "/chat",
|
||||||
"client.only": "/client-only",
|
"client.only": "/client-only",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
|
||||||
|
export const SearchDirectorySchema = v.object({
|
||||||
|
name: v.string().trim().optional(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||||
|
//
|
||||||
|
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
|
||||||
|
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
|
||||||
|
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||||
|
export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
|
||||||
|
export {};
|
||||||
+13
-2
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
|||||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||||
|
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
|
||||||
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||||
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||||
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
|
|||||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||||
}
|
}
|
||||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.41",
|
"version": "0.8.46",
|
||||||
"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",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
|||||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||||
// included in each published CLI bundle.
|
// included in each published CLI bundle.
|
||||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||||
import { parse } from "@wrnexus/syntax";
|
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||||
import { regenerateRoutes } from "./routes.ts";
|
import { regenerateRoutes } from "./routes.ts";
|
||||||
import { loadAppConfig } from "@wrnexus/styles";
|
import { loadAppConfig } from "@wrnexus/styles";
|
||||||
@@ -99,6 +99,78 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type assertions for sectioned api blocks.
|
||||||
|
*
|
||||||
|
* Enforcement lives here rather than in the compiler because this file is under
|
||||||
|
* `app/` and is therefore compiled by the project's own tsc, while generated
|
||||||
|
* build artifacts are not type-checked at all.
|
||||||
|
*/
|
||||||
|
function pageSlug(path: string): string {
|
||||||
|
// Block names are only unique within a single page (see apiBindingMap), so
|
||||||
|
// two pages each declaring e.g. `api search` is legal and would otherwise
|
||||||
|
// emit the identical `__wrn_api_check_search` type alias twice into this
|
||||||
|
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
|
||||||
|
// name with a short deterministic hash of the page's path (not the whole
|
||||||
|
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
|
||||||
|
// to keep names unique across the whole app while staying compact.
|
||||||
|
const normalized = path.replace(/\\/g, "/");
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
|
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiBlockAssertions(
|
||||||
|
pages: { path: string; ast: PageAst }[],
|
||||||
|
apiContracts: string,
|
||||||
|
appDir: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
// Hash the path relative to `app/`, not the absolute path: the absolute
|
||||||
|
// path varies with where the project checkout lives (e.g. a CI runner's
|
||||||
|
// temp clone vs. a developer's local path), which would make this file
|
||||||
|
// spuriously "stale" every time it's regenerated somewhere else.
|
||||||
|
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
|
||||||
|
for (const block of page.ast.dataApis) {
|
||||||
|
if (!block.sections) continue;
|
||||||
|
|
||||||
|
// ssr-mode sectioned blocks can never declare a `request` (they are
|
||||||
|
// render-time only), so they always fall back to the empty-shape
|
||||||
|
// `Record<string, never>` below. `keyof Record<string, never>` is
|
||||||
|
// `string`, which makes the key-exactness arm of AssertAssignable
|
||||||
|
// evaluate to `false` unconditionally and raises TS2344 on every such
|
||||||
|
// block regardless of whether the block author did anything wrong.
|
||||||
|
// We choose to skip emission for both (a) any non-client-mode block,
|
||||||
|
// since it structurally can never have a request to check, and (b) any
|
||||||
|
// block -- client included -- that has zero declared request fields,
|
||||||
|
// since there is nothing to assert type-safety about. This is more
|
||||||
|
// honest about intent than emitting a vacuous/always-failing check.
|
||||||
|
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||||
|
if (block.mode !== "client" || fields.length === 0) continue;
|
||||||
|
|
||||||
|
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||||
|
console.warn(
|
||||||
|
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
|
||||||
|
block.path,
|
||||||
|
)}, ${JSON.stringify(block.method)}>>>;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function generateApplicationTypes(
|
export function generateApplicationTypes(
|
||||||
appRoot: string,
|
appRoot: string,
|
||||||
pluginContributions?: PluginContributions,
|
pluginContributions?: PluginContributions,
|
||||||
@@ -145,6 +217,10 @@ export function generateApplicationTypes(
|
|||||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||||
|
path: file,
|
||||||
|
ast: parse(readFileSync(file, "utf8")),
|
||||||
|
}));
|
||||||
const typeDir = join(app, "types");
|
const typeDir = join(app, "types");
|
||||||
const apiContracts = router.api
|
const apiContracts = router.api
|
||||||
.map((route) => {
|
.map((route) => {
|
||||||
@@ -218,11 +294,33 @@ declare namespace WRNexusGenerated {
|
|||||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
mkdirSync(typeDir, { recursive: true });
|
mkdirSync(typeDir, { recursive: true });
|
||||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||||
writeFileSync(output, code, "utf8");
|
writeFileSync(output, code, "utf8");
|
||||||
|
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
|
||||||
|
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
|
||||||
|
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
|
||||||
|
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||||
|
//
|
||||||
|
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
|
||||||
|
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
|
||||||
|
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||||
|
${apiBlockAssertions(pageAsts, apiContracts, app)}
|
||||||
|
export {};
|
||||||
|
`;
|
||||||
|
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||||
writePluginArtifacts(root, pluginContributions);
|
writePluginArtifacts(root, pluginContributions);
|
||||||
return {
|
return {
|
||||||
file: relative(root, output).replace(/\\/g, "/"),
|
file: relative(root, output).replace(/\\/g, "/"),
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { generateApplicationTypes } from "../src/types.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Minimal app with one typed endpoint and one page that calls it. */
|
||||||
|
function fixture(block: string): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/search.wrn"),
|
||||||
|
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(generated).toContain("type AssertAssignable<");
|
||||||
|
expect(generated).toContain('type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"]');
|
||||||
|
expect(generated).toContain(
|
||||||
|
'type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The per-block assertions live in a plain .ts file, not the .d.ts: `skipLibCheck: true`
|
||||||
|
// (set repo-wide) exempts .d.ts *contents* from being checked at all, so a `.d.ts` can
|
||||||
|
// never actually enforce anything here. A real .ts file under app/ is compiled and
|
||||||
|
// checked normally.
|
||||||
|
test("emits one assertion per sectioned block, naming its route and method", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
|
||||||
|
expect(checks).toContain("name?: string");
|
||||||
|
expect(checks).toContain("age?: number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy bare-body block produces no assertion", () => {
|
||||||
|
const root = fixture(` api legacyUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the api-checks file has no runtime code and is a module", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).toContain("AUTO-GENERATED");
|
||||||
|
expect(checks.trim().endsWith("export {};")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B1: two pages each declaring a block with the same name do not collide", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/one.wrn"),
|
||||||
|
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/two.wrn"),
|
||||||
|
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
|
||||||
|
expect(names.length).toBe(2);
|
||||||
|
expect(new Set(names).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const GET = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/ssr.wrn"),
|
||||||
|
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check");
|
||||||
|
expect(checks).not.toContain("loadUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B2: a client block with an empty request emits no assertion", () => {
|
||||||
|
const root = fixture(` api pingServer GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check");
|
||||||
|
expect(checks).not.toContain("pingServer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
const assertionLine = checks
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
|
||||||
|
expect(assertionLine).toBeDefined();
|
||||||
|
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Real-compiler enforcement tests ---------------------------------------------
|
||||||
|
//
|
||||||
|
// Everything above only asserts on the emitted *text*. That proves nothing about
|
||||||
|
// whether the assertions actually make `tsc` fail — a build that reverted to the
|
||||||
|
// original inert `never`-based design, or one where `AssertAssignable` is merely
|
||||||
|
// one-directional (so it misses an *extra* declared field), would pass every test
|
||||||
|
// above unchanged. These tests instead run the real TypeScript compiler over the
|
||||||
|
// generated output and assert on its diagnostics.
|
||||||
|
//
|
||||||
|
// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback
|
||||||
|
// branch infers a real input type (`{ name: string; email: string }`) instead of
|
||||||
|
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
|
||||||
|
// could ever fail, which would make these tests meaningless.
|
||||||
|
function typedFixture(block: string): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/search.wrn"),
|
||||||
|
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles the two generated files (and whatever they reference on disk) with the
|
||||||
|
* real TypeScript compiler and returns its stdout plus whether it reported any
|
||||||
|
* diagnostics.
|
||||||
|
*/
|
||||||
|
function typecheckGenerated(root: string): { ok: boolean; output: string } {
|
||||||
|
const dts = join(root, "app/types/wrnexus.generated.d.ts");
|
||||||
|
const checks = join(root, "app/types/wrnexus.generated.api-checks.ts");
|
||||||
|
const result = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"bunx",
|
||||||
|
"tsc",
|
||||||
|
"--noEmit",
|
||||||
|
"--strict",
|
||||||
|
"--skipLibCheck",
|
||||||
|
"--moduleResolution",
|
||||||
|
"bundler",
|
||||||
|
"--target",
|
||||||
|
"ES2022",
|
||||||
|
"--module",
|
||||||
|
"ESNext",
|
||||||
|
dts,
|
||||||
|
checks,
|
||||||
|
],
|
||||||
|
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||||
|
return { ok: result.exitCode === 0, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("tsc: a block whose fields match the contract has no diagnostics", () => {
|
||||||
|
const root = typedFixture(MATCHING_BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(output.trim()).toBe("");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: number
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
extra: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a missing required field fails", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.11",
|
"version": "0.8.14",
|
||||||
"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);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"payload",
|
||||||
]);
|
]);
|
||||||
@@ -308,6 +309,32 @@ function _functionEntry(
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-mode api blocks become members of an `api` object in client scope.
|
||||||
|
*
|
||||||
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
|
*/
|
||||||
|
function apiBindings(ast: PageAst): string {
|
||||||
|
const members = ast.dataApis
|
||||||
|
.filter((block) => block.mode === "client" && block.sections)
|
||||||
|
.map((block) => {
|
||||||
|
const sections = block.sections!;
|
||||||
|
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
||||||
|
const error = eraseFunctionTypes(sections.error).trim();
|
||||||
|
const failure = error
|
||||||
|
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||||
|
: `(error) => { throw error; }`;
|
||||||
|
|
||||||
|
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
|
||||||
|
block.path,
|
||||||
|
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function generateBrowserModule(ast: PageAst): string {
|
export function generateBrowserModule(ast: PageAst): string {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||||
["legacy", "client", "shared"].includes(fn.runtime),
|
["legacy", "client", "shared"].includes(fn.runtime),
|
||||||
@@ -317,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
|
|||||||
const selectedImports = selectedBrowserImports(ast, functions);
|
const selectedImports = selectedBrowserImports(ast, functions);
|
||||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||||
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
|
// `api` is only defined as a client-scope binding when the page actually has
|
||||||
|
// client-mode api blocks (see apiBindings below). A page that declares
|
||||||
|
// `state api` without any client api blocks must keep reading/writing that
|
||||||
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
|
// there is a real `api` binding to shadow it.
|
||||||
|
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||||
|
const localRuntimeBindings = hasClientApi
|
||||||
|
? RUNTIME_BINDINGS
|
||||||
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||||
|
const sharedState = state.filter(
|
||||||
|
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
|
||||||
|
);
|
||||||
const sharedProps = ast.props
|
const sharedProps = ast.props
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.filter(
|
.filter(
|
||||||
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name),
|
(name) =>
|
||||||
|
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
|
||||||
);
|
);
|
||||||
const callableAliases = functionNames.filter(
|
const callableAliases = functionNames.filter(
|
||||||
(name) =>
|
(name) =>
|
||||||
@@ -365,6 +404,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ interface RenderBinding {
|
|||||||
path: string;
|
path: string;
|
||||||
body: string;
|
body: string;
|
||||||
helpers: string;
|
helpers: string;
|
||||||
|
// Present only for a sectioned ssr block with a non-empty `error {}` section.
|
||||||
|
// When set, a failed API call runs this body (with `status`/`message`/`data`
|
||||||
|
// bound) instead of propagating. Absent -> failures propagate, unchanged.
|
||||||
|
errorBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SsrBinding extends RenderBinding {
|
interface SsrBinding extends RenderBinding {
|
||||||
@@ -593,7 +597,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") {
|
||||||
@@ -858,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -934,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ssrRuntimeSource(): string {
|
function ssrRuntimeSource(): string {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -953,6 +983,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
|
|||||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -982,18 +1024,53 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -1502,8 +1579,11 @@ function generateInner(ast: PageAst): string {
|
|||||||
for (const [name, binding] of apiBindings) {
|
for (const [name, binding] of apiBindings) {
|
||||||
if (binding.mode !== "ssr") continue;
|
if (binding.mode !== "ssr") continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||||
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
loopConsts.push(
|
loopConsts.push(
|
||||||
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1511,7 +1591,9 @@ function generateInner(ast: PageAst): string {
|
|||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(
|
||||||
|
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
|
||||||
|
);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(
|
out.push(
|
||||||
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
@@ -2037,6 +2119,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 +2214,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 +2237,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 +2250,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,285 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generateTargets } from "../src/targets.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function browserModule(inner: string): string {
|
||||||
|
return generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
client {
|
||||||
|
${inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
console.log(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("emits an api member that calls the transport with the block's path and method", () => {
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(generated).toContain("const api =");
|
||||||
|
expect(generated).toContain("searchUsers");
|
||||||
|
expect(generated).toContain('"/api/users"');
|
||||||
|
expect(generated).toContain('"POST"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declared field types never reach the browser module", () => {
|
||||||
|
// The artifact is written as .mjs and parsed as JavaScript.
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(generated).not.toContain("name?: string");
|
||||||
|
expect(generated).not.toContain("age?: number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the emitted module is valid JavaScript", () => {
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a block without an error section still emits its response body", () => {
|
||||||
|
const generated = browserModule(` api plainUsers GET /api/users {
|
||||||
|
request {
|
||||||
|
parameters {
|
||||||
|
team: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("plainUsers");
|
||||||
|
expect(generated).toContain("data.users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("type annotations in response/error bodies are erased before emission (B4)", () => {
|
||||||
|
// Every other browser-bound body in the repo passes through eraseFunctionTypes
|
||||||
|
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
|
||||||
|
// store-codegen.ts); response/error bodies must too, for the same reason:
|
||||||
|
// eraseFunctionTypes strips function-signature annotations (params, return
|
||||||
|
// type, typed catch clauses) so a locally-declared helper function inside a
|
||||||
|
// response/error body no longer ships raw TypeScript into the .mjs artifact.
|
||||||
|
const generated = browserModule(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
function pick(list: string[]): string[] { return list }
|
||||||
|
return pick(data.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
function describe(e: unknown): string { return String(e) }
|
||||||
|
return describe(error)
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).not.toContain("list: string[]");
|
||||||
|
expect(generated).not.toContain("): string[] {");
|
||||||
|
expect(generated).not.toContain("e: unknown");
|
||||||
|
expect(generated).not.toContain("): string {");
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a page with state api and no client api blocks still reads that state (B5)", () => {
|
||||||
|
// "api" is normally excluded from state/prop destructuring because the
|
||||||
|
// emitted `const api = {...}` binding would shadow it -- but that binding
|
||||||
|
// only exists when the page has client-mode api blocks. Without one, the
|
||||||
|
// exclusion left `api` completely undeclared: a ReferenceError.
|
||||||
|
const generated = generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
state {
|
||||||
|
api = "hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client function run(): void {
|
||||||
|
console.log(api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
|
||||||
|
expect(generated).toContain("context.state");
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a browser module whose `run()` function calls api.searchUsers and
|
||||||
|
* reports the outcome through `output.report(...)` so the test can observe
|
||||||
|
* whether the call resolved or rejected without reaching into codegen
|
||||||
|
* internals.
|
||||||
|
*/
|
||||||
|
function reportingBrowserModule(apiBlock: string): string {
|
||||||
|
return generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
client {
|
||||||
|
${apiBlock}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
report(payload: any)
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
output.report({ ok: true, users })
|
||||||
|
} catch (e) {
|
||||||
|
output.report({ ok: false, message: String(e && e.message || e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBrowserModule(source: string): Promise<any> {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.mjs");
|
||||||
|
writeFileSync(file, source);
|
||||||
|
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a response body error is not swallowed by the error section (client)", async () => {
|
||||||
|
const mod = await importBrowserModule(
|
||||||
|
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||||
|
request { parameters { name: string } }
|
||||||
|
response {
|
||||||
|
return data.users.missing.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reports: unknown[] = [];
|
||||||
|
const context = {
|
||||||
|
state: {},
|
||||||
|
props: {},
|
||||||
|
output: { report: (value: unknown) => reports.push(value) },
|
||||||
|
server: {},
|
||||||
|
refs: {},
|
||||||
|
callApi: async () => ({ users: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.__wrnexusClientFunctions.run(context);
|
||||||
|
|
||||||
|
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
|
||||||
|
// The error section's own fallback ("[]" / an empty array) must not have
|
||||||
|
// been what the caller observed -- a bug in the response body is a
|
||||||
|
// rejection, not a silently-returned fallback value.
|
||||||
|
expect(reports[0]).not.toEqual({ ok: true, users: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
|
||||||
|
const mod = await importBrowserModule(
|
||||||
|
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||||
|
request { parameters { name: string } }
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reports: unknown[] = [];
|
||||||
|
const context = {
|
||||||
|
state: {},
|
||||||
|
props: {},
|
||||||
|
output: { report: (value: unknown) => reports.push(value) },
|
||||||
|
server: {},
|
||||||
|
refs: {},
|
||||||
|
callApi: async () => {
|
||||||
|
throw Object.assign(new Error("transport failed"), { status: 500 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.__wrnexusClientFunctions.run(context);
|
||||||
|
|
||||||
|
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a state field named api does not collide with the emitted api object", () => {
|
||||||
|
const generated = generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
state {
|
||||||
|
api = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
client {
|
||||||
|
${BLOCK}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
console.log(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generate } from "../src/codegen.ts";
|
||||||
|
|
||||||
|
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
|
||||||
|
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
|
||||||
|
// unrelated TypeScript version that doesn't understand this repo's tsconfig
|
||||||
|
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
|
||||||
|
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
|
||||||
|
// which uses this same local binary.
|
||||||
|
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
|
||||||
|
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
|
||||||
|
// file that's actually invoked (our temp one), not the base file — so the
|
||||||
|
// ambient `bun` types need an explicit path back to the repo's node_modules.
|
||||||
|
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the real TypeScript compiler over a generated server module. Proves
|
||||||
|
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
|
||||||
|
* module) actually type-checks — string-containment assertions alone can't
|
||||||
|
* catch a declared type that omits a field every emitted object literal has.
|
||||||
|
*/
|
||||||
|
function typecheckGenerated(source: string): { ok: boolean; output: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
|
||||||
|
roots.push(root);
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, source);
|
||||||
|
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
|
||||||
|
// checks the one file we care about instead of hand-duplicating the whole
|
||||||
|
// compiler configuration (and drifting from it over time).
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
extends: ROOT_TSCONFIG,
|
||||||
|
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
|
||||||
|
include: ["page.ts"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
|
||||||
|
cwd: root,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||||
|
return { ok: result.exitCode === 0, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverModule(inner: string): string {
|
||||||
|
return generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
${inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><p api="ssrUsers">loading</p></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a sectioned ssr block binds the payload to data", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("data.users.length");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy ssr block is unchanged", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("users.length");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return message + status + data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain('"errorBody"');
|
||||||
|
expect(generated).toContain("return message + status + data");
|
||||||
|
expect(generated).toContain("const status = $status");
|
||||||
|
expect(generated).toContain("const message = $message");
|
||||||
|
expect(generated).toContain("const data = $data");
|
||||||
|
expect(generated).toContain("__wrnexusEvalError");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block without an error section emits no catch entry for that binding", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).not.toContain('"errorBody"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return message + status + data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
const { ok, output } = typecheckGenerated(generated);
|
||||||
|
|
||||||
|
expect(output.trim()).toBe("");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
const html = await mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block's response body error is not swallowed by the error section", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.missing.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => ({ users: [] }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block still runs the error body on a genuine transport failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
const html = await mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
});
|
||||||
@@ -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/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -96,7 +96,21 @@ export function defineEndpoint(
|
|||||||
if (definition.auth === "required" && !ctx.user) {
|
if (definition.auth === "required" && !ctx.user) {
|
||||||
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
||||||
}
|
}
|
||||||
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput;
|
// The real HTTP router invokes route handlers as `handler(ctx)` — it never
|
||||||
|
// supplies a second argument. Callers that already have a parsed payload
|
||||||
|
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
|
||||||
|
// that always wins. Otherwise, read the request ourselves: query params for
|
||||||
|
// GET/HEAD, JSON body for everything else.
|
||||||
|
let input: unknown = rawInput;
|
||||||
|
if (definition.input) {
|
||||||
|
const resolvedInput =
|
||||||
|
rawInput !== undefined
|
||||||
|
? rawInput
|
||||||
|
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
|
||||||
|
? Object.fromEntries(ctx.url.searchParams)
|
||||||
|
: await ctx.req.json().catch(() => ({}));
|
||||||
|
input = schemaValue(definition.input, resolvedInput);
|
||||||
|
}
|
||||||
const rawOutput = await definition.handler(input, ctx);
|
const rawOutput = await definition.handler(input, ctx);
|
||||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||||
return output instanceof Response ? output : json({ data: output });
|
return output instanceof Response ? output : json({ data: output });
|
||||||
|
|||||||
@@ -29,3 +29,108 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
|
|||||||
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
||||||
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
|
||||||
|
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
|
||||||
|
// passes rawInput explicitly, so it never exercises that calling convention. These
|
||||||
|
// tests call the endpoint with only a context, matching what actually happens in
|
||||||
|
// production, to guard against silently validating `undefined` again.
|
||||||
|
const search = v.object({ name: v.string().trim().optional() });
|
||||||
|
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
|
||||||
|
input: search,
|
||||||
|
handler(input) {
|
||||||
|
return { name: input.name ?? null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a GET request reads input from the URL's query string", async () => {
|
||||||
|
const request = new Request("https://example.test/api/search?name=Ada");
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
|
||||||
|
const request = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "Ada" }),
|
||||||
|
});
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
|
||||||
|
const malformedRequest = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "{not json",
|
||||||
|
});
|
||||||
|
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
|
||||||
|
const malformedResponse = await searchEndpoint(malformedCtx);
|
||||||
|
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
|
||||||
|
// the point is that the malformed body did not throw an unhandled parse error.
|
||||||
|
expect(malformedResponse.status).toBe(200);
|
||||||
|
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
|
||||||
|
|
||||||
|
const requiredField = v.object({ name: v.string().min(1) });
|
||||||
|
const requiredEndpoint = defineEndpoint({
|
||||||
|
input: requiredField,
|
||||||
|
handler(input) {
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
|
||||||
|
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
|
||||||
|
const emptyResponse = await requiredEndpoint(emptyCtx);
|
||||||
|
// With no body at all, resolved input is {} — the schema's own required-field
|
||||||
|
// validation is what turns that into a 400, not a thrown parse error.
|
||||||
|
expect(emptyResponse.status).toBe(400);
|
||||||
|
expect(await emptyResponse.json()).toEqual({
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "Endpoint validation failed.",
|
||||||
|
details: { name: "Required" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET query strings travel as text (`URLSearchParams` values are always
|
||||||
|
// strings), so a `v.number()` field must come back as a real number, not the
|
||||||
|
// string the wire actually carried, or a page declaring `age?: number` on a
|
||||||
|
// GET api block would be lying about the type. checkField in
|
||||||
|
// @wrnexus/validation coerces via Number(pre) for both optional and required
|
||||||
|
// number fields (see packages/validation/src/index.ts); this locks that in
|
||||||
|
// end-to-end through defineEndpoint's own GET query-string resolution path.
|
||||||
|
test("a GET request coerces a v.number() query param to an actual number", async () => {
|
||||||
|
const ageSchema = v.object({ age: v.number() });
|
||||||
|
const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({
|
||||||
|
input: ageSchema,
|
||||||
|
handler(input) {
|
||||||
|
return { age: input.age, typeofAge: typeof input.age };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request("https://example.test/api/age?age=30");
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await ageEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit rawInput argument still wins and the request is never read", async () => {
|
||||||
|
// A request whose body has already been consumed: if the endpoint tried to read it
|
||||||
|
// again (rather than trusting the explicit rawInput), this would throw.
|
||||||
|
const request = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "ignored-body" }),
|
||||||
|
});
|
||||||
|
await request.json(); // drain the body so a second .json() call would reject
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx, { name: "Explicit" });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.25",
|
||||||
"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;
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncI18n(nextDocument) {
|
function syncI18n(nextDocument) {
|
||||||
var script = Array.prototype.find.call(
|
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
nextDocument.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
|
||||||
if (!match) return;
|
|
||||||
try {
|
try {
|
||||||
var incoming = JSON.parse(match[1]);
|
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||||
var current = window.__wrnI18n || {};
|
var current = window.__wrnI18n || {};
|
||||||
var translator = current.t;
|
var translator = current.t;
|
||||||
var setter = current.set;
|
var setter = current.set;
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -1388,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
state: stateProxy,
|
state: stateProxy,
|
||||||
output: outputProxy,
|
output: outputProxy,
|
||||||
server: serverProxy,
|
server: serverProxy,
|
||||||
|
callApi: wrnexusCallApi,
|
||||||
props: propsProxy,
|
props: propsProxy,
|
||||||
refs: refsProxy,
|
refs: refsProxy,
|
||||||
};
|
};
|
||||||
@@ -1501,19 +1494,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 +1584,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 +1598,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (
|
if (
|
||||||
attribute.name === "data-text"
|
attribute.name === "data-text"
|
||||||
) {
|
) {
|
||||||
try {
|
(function (textNode, textExpression) {
|
||||||
var textValue =
|
var runText = reactive(function () {
|
||||||
itemEval(attribute.value);
|
try {
|
||||||
|
var textValue = itemEval(textExpression);
|
||||||
node.textContent =
|
textNode.textContent = textValue == null ? "" : String(textValue);
|
||||||
textValue == null
|
} catch (error) {
|
||||||
? ""
|
console.error(
|
||||||
: String(textValue);
|
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
|
||||||
} catch (error) {
|
error,
|
||||||
console.error(
|
);
|
||||||
"[wrnexus] data-for text binding failed for '" +
|
textNode.textContent = "";
|
||||||
attribute.value +
|
}
|
||||||
"'",
|
});
|
||||||
error,
|
runText();
|
||||||
);
|
})(node, attribute.value);
|
||||||
|
|
||||||
node.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1642,28 +1626,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 +1661,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 +1735,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 +1887,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 +1906,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 +2082,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 +2255,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 +2328,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 +2424,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 +2464,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 +2472,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 +2483,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 +2578,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 +2608,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 +2657,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 +2674,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 +2712,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 +2734,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 +3095,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 +3629,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);
|
||||||
@@ -4171,6 +4247,72 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Transport for compiled api blocks.
|
||||||
|
*
|
||||||
|
* Only the request and the failure shape live here. A block's response and
|
||||||
|
* error bodies are page code, so they are emitted into the browser module
|
||||||
|
* and applied by the caller.
|
||||||
|
*/
|
||||||
|
function readCsrfToken() {
|
||||||
|
var meta = document.querySelector('meta[name="wrnexus-csrf"]');
|
||||||
|
if (meta) return meta.getAttribute("content") || "";
|
||||||
|
var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrnexusCallApi(path, method, input) {
|
||||||
|
var verb = String(method || "GET").toUpperCase();
|
||||||
|
var values = input || {};
|
||||||
|
var url = path;
|
||||||
|
var headers = { accept: "application/json" };
|
||||||
|
var init = { method: verb, credentials: "same-origin", headers: headers };
|
||||||
|
|
||||||
|
if (verb === "GET" || verb === "HEAD") {
|
||||||
|
var query = [];
|
||||||
|
Object.keys(values).forEach(function (key) {
|
||||||
|
var value = values[key];
|
||||||
|
// An omitted filter must not become "name=undefined".
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)));
|
||||||
|
});
|
||||||
|
if (query.length) url = path + "?" + query.join("&");
|
||||||
|
} else {
|
||||||
|
headers["content-type"] = "application/json";
|
||||||
|
headers["x-csrf-token"] = readCsrfToken();
|
||||||
|
init.body = JSON.stringify(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, init).then(function (response) {
|
||||||
|
// A 2xx with no body (204/205, or a genuinely empty response) is a
|
||||||
|
// success, not a parse failure -- the failure table only calls for the
|
||||||
|
// error path on non-2xx, network failure, or an unparseable body.
|
||||||
|
if (response.ok && (response.status === 204 || response.status === 205)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return response.json().then(
|
||||||
|
function (data) {
|
||||||
|
if (response.ok) return data;
|
||||||
|
var message =
|
||||||
|
data && data.error ? String(data.error) : "Request failed with " + response.status;
|
||||||
|
var failure = new Error(message);
|
||||||
|
failure.status = response.status;
|
||||||
|
failure.data = data;
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
if (response.ok) return undefined;
|
||||||
|
var failure = new Error("Response was not valid JSON");
|
||||||
|
failure.status = response.status;
|
||||||
|
failure.data = undefined;
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__wrnexusCallApi = wrnexusCallApi;
|
||||||
|
|
||||||
function dispatchComponentEvent(root, name, detail) {
|
function dispatchComponentEvent(root, name, detail) {
|
||||||
if (!root || !name) return null;
|
if (!root || !name) return null;
|
||||||
var EventConstructor =
|
var EventConstructor =
|
||||||
@@ -4195,7 +4337,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 +4359,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 +4793,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 +4905,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 +5951,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,128 @@
|
|||||||
|
import { expect, test, beforeEach } from "bun:test";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
url: string;
|
||||||
|
init: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
|
||||||
|
function harness(response: { status: number; payload: unknown }) {
|
||||||
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||||
|
const calls: Call[] = [];
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).document = win.document;
|
||||||
|
(globalThis as Record<string, unknown>).location = win.location;
|
||||||
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||||
|
win as unknown as { NodeFilter: unknown }
|
||||||
|
).NodeFilter;
|
||||||
|
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: response.status >= 200 && response.status < 300,
|
||||||
|
status: response.status,
|
||||||
|
json: () => Promise.resolve(response.payload),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
(0, eval)(REACTIVE_RUNTIME);
|
||||||
|
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||||
|
.__wrnexusCallApi;
|
||||||
|
return { callApi, calls, win };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GET builds a query string and omits undefined fields", async () => {
|
||||||
|
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
|
||||||
|
|
||||||
|
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
|
||||||
|
|
||||||
|
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
|
||||||
|
expect(calls[0]!.init.method).toBe("GET");
|
||||||
|
expect(calls[0]!.init.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST sends a JSON body", async () => {
|
||||||
|
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
|
||||||
|
|
||||||
|
await callApi("/api/users", "POST", { name: "Ajay" });
|
||||||
|
|
||||||
|
expect(calls[0]!.url).toBe("/api/users");
|
||||||
|
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||||
|
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||||
|
win.document.cookie = "wrn-csrf=token-123";
|
||||||
|
|
||||||
|
await callApi("/api/users", "POST", {});
|
||||||
|
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 2xx resolves to the parsed payload", async () => {
|
||||||
|
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
|
||||||
|
|
||||||
|
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
|
||||||
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).document = win.document;
|
||||||
|
(globalThis as Record<string, unknown>).location = win.location;
|
||||||
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||||
|
win as unknown as { NodeFilter: unknown }
|
||||||
|
).NodeFilter;
|
||||||
|
(globalThis as Record<string, unknown>).fetch = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||||
|
});
|
||||||
|
|
||||||
|
(0, eval)(REACTIVE_RUNTIME);
|
||||||
|
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||||
|
.__wrnexusCallApi;
|
||||||
|
|
||||||
|
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
|
||||||
|
const { callApi } = harness({ status: 200, payload: undefined });
|
||||||
|
(globalThis as Record<string, unknown>).fetch = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-2xx rejects with status, message and data", async () => {
|
||||||
|
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||||
|
|
||||||
|
const failure = await callApi("/api/users", "GET", {}).catch(
|
||||||
|
(error: Error & { status?: number; data?: unknown }) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure.status).toBe(400);
|
||||||
|
expect(failure.message).toContain("Bad filter");
|
||||||
|
expect(failure.data).toEqual({ error: "Bad filter" });
|
||||||
|
});
|
||||||
@@ -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,18 +37,22 @@ function install(bodyHtml: string): void {
|
|||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = [
|
||||||
|
"window",
|
||||||
|
"document",
|
||||||
|
"history",
|
||||||
|
"location",
|
||||||
|
"DOMParser",
|
||||||
|
"CustomEvent",
|
||||||
|
"Event",
|
||||||
|
"fetch",
|
||||||
|
];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const g = globalThis as any;
|
const g = globalThis as any;
|
||||||
for (const k of [
|
for (const k of REPLACED_GLOBALS) {
|
||||||
"window",
|
|
||||||
"document",
|
|
||||||
"history",
|
|
||||||
"location",
|
|
||||||
"DOMParser",
|
|
||||||
"CustomEvent",
|
|
||||||
"Event",
|
|
||||||
"fetch",
|
|
||||||
]) {
|
|
||||||
delete g[k];
|
delete g[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,7 +153,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
|||||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
@@ -180,7 +185,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
|||||||
};
|
};
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
|
|||||||
@@ -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/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -230,5 +230,12 @@ export function generateQueriesFile(
|
|||||||
if (usedModels.size > 0) {
|
if (usedModels.size > 0) {
|
||||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
||||||
}
|
}
|
||||||
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
|
// The dialect is stamped into the header because it changes the emitted SQL:
|
||||||
|
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
|
||||||
|
// under a different profile therefore rewrites this committed file, and
|
||||||
|
// without the stamp the diff looks like unexplained churn.
|
||||||
|
return (
|
||||||
|
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
|
||||||
|
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
|
||||||
|
|
||||||
|
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
|
||||||
|
|
||||||
|
test("the generated header records the dialect it was built for", () => {
|
||||||
|
// The same command emits different SQL per dialect, so a build under another
|
||||||
|
// profile rewrites the committed file. The stamp makes that visible in the
|
||||||
|
// diff instead of looking like unexplained churn.
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placeholder style follows the dialect", () => {
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation is deterministic for a fixed dialect", () => {
|
||||||
|
const first = generateQueriesFile(queries, [], "postgres");
|
||||||
|
const second = generateQueriesFile(queries, [], "postgres");
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.37",
|
"version": "0.8.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
|||||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayWebSocketOriginAllowed(
|
export function gatewayWebSocketOriginAllowed(
|
||||||
req: Request,
|
req: Request,
|
||||||
target: Target,
|
target: Target,
|
||||||
configured: string[],
|
configured: string[],
|
||||||
@@ -174,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
|||||||
}
|
}
|
||||||
if (configured.includes(origin)) return true;
|
if (configured.includes(origin)) return true;
|
||||||
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
||||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
// Compare hostnames, not hosts: configured domains carry no port, while the
|
||||||
|
// browser's Origin does. publicOrigin above only ever matches domains[0], so
|
||||||
|
// every other domain fell through to here and was denied purely on the port,
|
||||||
|
// which left the HMR socket reconnecting forever on those hosts.
|
||||||
|
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -466,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;
|
||||||
@@ -493,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}`]),
|
||||||
@@ -733,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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
var i18nScript = Array.prototype.find.call(
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
doc.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (i18nScript) {
|
if (i18nScript) {
|
||||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
try {
|
||||||
if (i18nMatch) {
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
try {
|
var existingI18n = window.__wrnI18n || {};
|
||||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
incomingI18n.t = existingI18n.t;
|
||||||
var existingI18n = window.__wrnI18n || {};
|
incomingI18n.set = existingI18n.set;
|
||||||
incomingI18n.t = existingI18n.t;
|
window.__wrnI18n = incomingI18n;
|
||||||
incomingI18n.set = existingI18n.set;
|
} catch (error) {
|
||||||
window.__wrnI18n = incomingI18n;
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
} catch (error) {
|
|
||||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
name: "web",
|
||||||
|
origin: "http://127.0.0.1:3101",
|
||||||
|
domains: ["localhost", "web.localhost"],
|
||||||
|
publicOrigin: "http://localhost:3000",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function upgrade(origin: string, host: string): Request {
|
||||||
|
return new Request("http://" + host + "/__wrnexus/hmr", {
|
||||||
|
headers: { origin, host, upgrade: "websocket" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows an upgrade from the app's primary domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an upgrade from a secondary domain on a non-default port", () => {
|
||||||
|
// publicOrigin is built from domains[0], so a browser on web.localhost falls
|
||||||
|
// through to the domain list — where the origin host still carries :3000 and
|
||||||
|
// the configured domain does not. That mismatch denied every HMR socket on
|
||||||
|
// any domain but the first, leaving the client reconnecting forever.
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://web.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies an unrelated origin", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://evil.example:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies a lookalike suffix domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -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/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.12",
|
"version": "0.8.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
|||||||
.replace(/\u2029/g, "\\u2029");
|
.replace(/\u2029/g, "\\u2029");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||||
|
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||||
|
*
|
||||||
|
* It ships inside a `type="application/json"` block, which the browser never
|
||||||
|
* executes, so `script-src` does not apply to it. As an inline executable
|
||||||
|
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||||
|
* from a different response, leaving window.__wrnI18n undefined.
|
||||||
|
*/
|
||||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||||
return `window.__wrnI18n=${safeJson({
|
return `${safeJson({
|
||||||
lang: active,
|
lang: active,
|
||||||
langs: i18n.langs,
|
langs: i18n.langs,
|
||||||
default: i18n.default,
|
default: i18n.default,
|
||||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
|||||||
directions: i18n.direction,
|
directions: i18n.direction,
|
||||||
labels: i18n.labels,
|
labels: i18n.labels,
|
||||||
cookie: i18n.cookie,
|
cookie: i18n.cookie,
|
||||||
})};`;
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full JSON block, including its script tag. */
|
||||||
|
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||||
|
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const I18N_RUNTIME = String.raw`
|
export const I18N_RUNTIME = String.raw`
|
||||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function state() { return window.__wrnI18n || {}; }
|
function readDataBlock() {
|
||||||
|
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function state() {
|
||||||
|
if (!window.__wrnI18n) {
|
||||||
|
var data = readDataBlock();
|
||||||
|
if (data) window.__wrnI18n = data;
|
||||||
|
}
|
||||||
|
return window.__wrnI18n || {};
|
||||||
|
}
|
||||||
function t(key, params) {
|
function t(key, params) {
|
||||||
var current = state();
|
var current = state();
|
||||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||||
|
|
||||||
|
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||||
|
|
||||||
|
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||||
|
const data = renderI18nData(i18n, "en");
|
||||||
|
expect(() => JSON.parse(data)).not.toThrow();
|
||||||
|
expect(data).not.toContain("window.__wrnI18n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data tag is a non-executable JSON block", () => {
|
||||||
|
// An executable inline script is subject to script-src and gets blocked
|
||||||
|
// whenever the document's CSP nonce came from a different response, which is
|
||||||
|
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||||
|
const tag = renderI18nDataTag(i18n, "es");
|
||||||
|
expect(tag).toContain('type="application/json"');
|
||||||
|
expect(tag).toContain("data-wrn-i18n");
|
||||||
|
expect(tag).not.toContain("nonce=");
|
||||||
|
expect(tag).toContain("Hola");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||||
|
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
});
|
||||||
@@ -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/syntax",
|
"name": "@wrnexus/syntax",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Parse the sectioned form of an `api` block body.
|
||||||
|
*
|
||||||
|
* Returns null when no section keyword is present, which is how the legacy
|
||||||
|
* bare-body form stays valid: the caller keeps treating the body as the
|
||||||
|
* response expression.
|
||||||
|
*
|
||||||
|
* Detection and slicing both drive the tokenizer's own string/comment-aware
|
||||||
|
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
|
||||||
|
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
|
||||||
|
* mentioned in a comment can't be mistaken for a real section.
|
||||||
|
*/
|
||||||
|
import { Lexer, LexError, isIdentPart, isIdentStart, skipLiteralOrComment } from "./tokenizer.ts";
|
||||||
|
|
||||||
|
export interface ApiFieldDecl {
|
||||||
|
name: string;
|
||||||
|
optional: boolean;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSections {
|
||||||
|
parameters: ApiFieldDecl[];
|
||||||
|
body: ApiFieldDecl[];
|
||||||
|
response: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_NAMES = ["request", "response", "error"] as const;
|
||||||
|
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"] as const;
|
||||||
|
|
||||||
|
interface Span {
|
||||||
|
text: string;
|
||||||
|
/** Offset of `text[0]` within the source that was scanned. */
|
||||||
|
start: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
|
||||||
|
* one of `names`. Strings, template literals, and comments are skipped via
|
||||||
|
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
|
||||||
|
* keyword mentioned inside a string or comment, or nested inside an unrelated
|
||||||
|
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
|
||||||
|
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
|
||||||
|
* not a reimplementation of it.
|
||||||
|
*/
|
||||||
|
function scanTopLevelBlocks(source: string, names: readonly string[]): Map<string, Span> {
|
||||||
|
const found = new Map<string, Span>();
|
||||||
|
const lx = new Lexer(source);
|
||||||
|
let depth = 0;
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
|
||||||
|
while (i < source.length) {
|
||||||
|
const c = source[i]!;
|
||||||
|
|
||||||
|
if (c === "\n") {
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = skipLiteralOrComment(source, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
||||||
|
|
||||||
|
if (depth === 0 && isIdentStart(c)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < source.length && isIdentPart(source[j]!)) j++;
|
||||||
|
const word = source.slice(i, j);
|
||||||
|
|
||||||
|
// Skip trivia between the identifier and a possible '{' without
|
||||||
|
// treating anything in between as significant yet.
|
||||||
|
let k = j;
|
||||||
|
let lineStartAtK = false;
|
||||||
|
while (k < source.length) {
|
||||||
|
const kc = source[k]!;
|
||||||
|
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kc === "\n") {
|
||||||
|
lineStartAtK = true;
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kSkipped = skipLiteralOrComment(source, k, lineStartAtK);
|
||||||
|
if (kSkipped !== null) {
|
||||||
|
k = kSkipped;
|
||||||
|
lineStartAtK = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.includes(word) && source[k] === "{") {
|
||||||
|
lx.pos = k;
|
||||||
|
const start = k + 1;
|
||||||
|
const text = lx.readBalancedBraces();
|
||||||
|
if (!found.has(word)) found.set(word, { text, start });
|
||||||
|
i = lx.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === "{") depth++;
|
||||||
|
else if (c === "}") depth = Math.max(0, depth - 1);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebase a span captured from `outer.text` back onto the original source. */
|
||||||
|
function absolutize(span: Span | undefined, outer: Span | undefined): Span | undefined {
|
||||||
|
if (!span) return undefined;
|
||||||
|
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||||
|
function parseFields(span: Span | undefined): ApiFieldDecl[] {
|
||||||
|
if (!span) return [];
|
||||||
|
const fields: ApiFieldDecl[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const rawLine of span.text.split("\n")) {
|
||||||
|
const lineOffset = span.start + cursor;
|
||||||
|
cursor += rawLine.length + 1;
|
||||||
|
|
||||||
|
const line = rawLine.trim().replace(/,$/, "");
|
||||||
|
if (!line || line.startsWith("//")) continue;
|
||||||
|
|
||||||
|
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
throw new LexError(
|
||||||
|
`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({ name: match[1]!, optional: match[2] === "?", type: match[3]!.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiSections(source: string): ApiSections | null {
|
||||||
|
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||||
|
if (top.size === 0) return null;
|
||||||
|
|
||||||
|
const request = top.get("request");
|
||||||
|
const sub = request
|
||||||
|
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
|
||||||
|
: new Map<string, Span>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||||
|
body: parseFields(absolutize(sub.get("body"), request)),
|
||||||
|
response: top.get("response")?.text ?? "",
|
||||||
|
error: top.get("error")?.text ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the block declares a `request` section. */
|
||||||
|
export function hasRequestSection(source: string): boolean {
|
||||||
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||||
|
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
@@ -148,7 +149,10 @@ export interface DataApiBlock {
|
|||||||
name: string;
|
name: string;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Legacy bare body. Empty string when `sections` is set. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Present only for the sectioned, typed form. */
|
||||||
|
sections?: ApiSections;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModeFunctionsBlock {
|
export interface ModeFunctionsBlock {
|
||||||
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name, method, path, body });
|
const sections = parseApiSections(body);
|
||||||
|
if (sections && mode !== "client" && hasRequestSection(body)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dataApis.push({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: sections ? "" : body,
|
||||||
|
...(sections ? { sections } : {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
|
|||||||
@@ -31,8 +31,47 @@ export interface Token {
|
|||||||
export class LexError extends Error {}
|
export class LexError extends Error {}
|
||||||
|
|
||||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
export const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
export const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip over a string/template literal or comment starting at `src[i]`, using
|
||||||
|
* the exact rules `readBalancedBraces` needs to stay comment- and
|
||||||
|
* string-aware: `/* block *\/` comments anywhere, `//` line comments only at
|
||||||
|
* the start of a line (so a bare `https://…` in view text isn't mistaken for
|
||||||
|
* one), and `"`, `'`, `` ` `` strings with backslash escapes.
|
||||||
|
*
|
||||||
|
* Returns the index just past what it skipped, or `null` when `src[i]` isn't
|
||||||
|
* the start of one of those. Exported so any other raw-body scanner that
|
||||||
|
* needs to walk `.wrn` source without tripping over strings or comments
|
||||||
|
* (e.g. the `api` section scanner) shares this logic instead of
|
||||||
|
* reimplementing it — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces.
|
||||||
|
*/
|
||||||
|
export function skipLiteralOrComment(src: string, i: number, atLineStart: boolean): number | null {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === "/" && src[i + 1] === "*") {
|
||||||
|
const close = src.indexOf("*/", i + 2);
|
||||||
|
return close === -1 ? src.length : close + 2;
|
||||||
|
}
|
||||||
|
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||||
|
const newline = src.indexOf("\n", i + 2);
|
||||||
|
return newline === -1 ? src.length : newline;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length) {
|
||||||
|
if (src[j] === "\\") {
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (src[j] === c) return j + 1;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return src.length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class Lexer {
|
export class Lexer {
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -312,46 +351,26 @@ export class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str: string | null = null;
|
|
||||||
/** True while only whitespace has been seen since the last newline. */
|
/** True while only whitespace has been seen since the last newline. */
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (; i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i]!;
|
const c = src[i]!;
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str) str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === "\n") {
|
if (c === "\n") {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1) break; // unterminated: fall through to the error
|
i = skipped;
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
|
||||||
const newline = src.indexOf("\n", i + 2);
|
|
||||||
if (newline === -1) break;
|
|
||||||
i = newline - 1; // let the loop's own increment land on the newline
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
||||||
|
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{") depth++;
|
if (c === "{") depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
depth--;
|
depth--;
|
||||||
@@ -360,6 +379,7 @@ export class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parse } from "../src/index.ts";
|
||||||
|
|
||||||
|
const page = (inner: string) => `page Repro {
|
||||||
|
client {
|
||||||
|
${inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("parses a sectioned api block into request, response and error", () => {
|
||||||
|
const ast = parse(
|
||||||
|
page(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const block = ast.dataApis[0]!;
|
||||||
|
expect(block.name).toBe("searchUsers");
|
||||||
|
expect(block.method).toBe("POST");
|
||||||
|
expect(block.path).toBe("/api/users");
|
||||||
|
expect(block.sections?.body).toEqual([
|
||||||
|
{ name: "name", optional: true, type: "string" },
|
||||||
|
{ name: "age", optional: true, type: "number" },
|
||||||
|
]);
|
||||||
|
expect(block.sections?.response.trim()).toBe("return data.users");
|
||||||
|
expect(block.sections?.error.trim()).toBe("return []");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bare body still parses as the legacy response body", () => {
|
||||||
|
const ast = parse(
|
||||||
|
page(` api legacyUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const block = ast.dataApis[0]!;
|
||||||
|
expect(block.sections).toBeUndefined();
|
||||||
|
expect(block.body.trim()).toBe("return users.length");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET parameters are parsed as required when not marked optional", () => {
|
||||||
|
const ast = parse(
|
||||||
|
page(` api listUsers GET /api/users {
|
||||||
|
request {
|
||||||
|
parameters {
|
||||||
|
team: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ast.dataApis[0]!.sections?.parameters).toEqual([
|
||||||
|
{ name: "team", optional: false, type: "string" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("request inside an ssr block is rejected with a message naming the restriction", () => {
|
||||||
|
const source = `page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
request {
|
||||||
|
parameters {
|
||||||
|
team: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(() => parse(source)).toThrow(/ssr[\s\S]*request/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a brace inside a string literal in the response body does not truncate the section", () => {
|
||||||
|
const ast = parse(
|
||||||
|
page(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return "a } weird string"
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const block = ast.dataApis[0]!;
|
||||||
|
expect(block.sections?.response.trim()).toBe('return "a } weird string"');
|
||||||
|
expect(block.sections?.error.trim()).toBe("return []");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy block whose comment or string mentions a section keyword stays legacy", () => {
|
||||||
|
const ast = parse(
|
||||||
|
page(` api legacyUsers GET /api/users {
|
||||||
|
// fall back to a manual request { } if this fails
|
||||||
|
return "response { not a section }"
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const block = ast.dataApis[0]!;
|
||||||
|
expect(block.sections).toBeUndefined();
|
||||||
|
expect(block.body.trim()).toBe(
|
||||||
|
'// fall back to a manual request { } if this fails\n return "response { not a section }"',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -114,7 +114,28 @@ export function checkField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (desc.type === "boolean") {
|
if (desc.type === "boolean") {
|
||||||
const value = raw === true || raw === "true" || raw === "on";
|
const empty = raw === undefined || raw === null || raw === "";
|
||||||
|
let value: boolean;
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
value = raw;
|
||||||
|
} else if (empty) {
|
||||||
|
value = false;
|
||||||
|
} else if (raw === 1) {
|
||||||
|
value = true;
|
||||||
|
} else if (raw === 0) {
|
||||||
|
value = false;
|
||||||
|
} else if (typeof raw === "string") {
|
||||||
|
const norm = raw.trim().toLowerCase();
|
||||||
|
if (norm === "true" || norm === "on" || norm === "1" || norm === "yes") {
|
||||||
|
value = true;
|
||||||
|
} else if (norm === "false" || norm === "off" || norm === "0" || norm === "no") {
|
||||||
|
value = false;
|
||||||
|
} else {
|
||||||
|
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
|
||||||
|
}
|
||||||
if (!desc.optional && !value) {
|
if (!desc.optional && !value) {
|
||||||
return { value, error: desc.requiredMessage || "Required" };
|
return { value, error: desc.requiredMessage || "Required" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,28 @@ export const VALIDATE_RUNTIME = String.raw`
|
|||||||
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
|
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
|
||||||
}
|
}
|
||||||
if (desc.type === "boolean") {
|
if (desc.type === "boolean") {
|
||||||
var b = raw === true || raw === "true" || raw === "on";
|
var bEmpty = raw === undefined || raw === null || raw === "";
|
||||||
|
var b;
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
b = raw;
|
||||||
|
} else if (bEmpty) {
|
||||||
|
b = false;
|
||||||
|
} else if (raw === 1) {
|
||||||
|
b = true;
|
||||||
|
} else if (raw === 0) {
|
||||||
|
b = false;
|
||||||
|
} else if (typeof raw === "string") {
|
||||||
|
var bNorm = raw.trim().toLowerCase();
|
||||||
|
if (bNorm === "true" || bNorm === "on" || bNorm === "1" || bNorm === "yes") {
|
||||||
|
b = true;
|
||||||
|
} else if (bNorm === "false" || bNorm === "off" || bNorm === "0" || bNorm === "no") {
|
||||||
|
b = false;
|
||||||
|
} else {
|
||||||
|
return desc.typeMessage || "Must be true or false";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return desc.typeMessage || "Must be true or false";
|
||||||
|
}
|
||||||
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
||||||
}
|
}
|
||||||
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
||||||
|
|||||||
@@ -114,6 +114,140 @@ test("checkField coerces and applies rules", () => {
|
|||||||
).toBe("Email is required");
|
).toBe("Email is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("checkField coerces recognised true/false boolean strings, case-insensitively and trimmed", () => {
|
||||||
|
const desc = { type: "boolean" as const, optional: true, rules: [] };
|
||||||
|
for (const raw of [
|
||||||
|
"true",
|
||||||
|
"TRUE",
|
||||||
|
" True ",
|
||||||
|
"on",
|
||||||
|
"ON",
|
||||||
|
" on ",
|
||||||
|
"1",
|
||||||
|
" 1 ",
|
||||||
|
"yes",
|
||||||
|
"YES",
|
||||||
|
" Yes ",
|
||||||
|
]) {
|
||||||
|
expect(checkField(desc, raw)).toEqual({ value: true, error: null });
|
||||||
|
}
|
||||||
|
for (const raw of [
|
||||||
|
"false",
|
||||||
|
"FALSE",
|
||||||
|
" False ",
|
||||||
|
"off",
|
||||||
|
"OFF",
|
||||||
|
" off ",
|
||||||
|
"0",
|
||||||
|
" 0 ",
|
||||||
|
"no",
|
||||||
|
"NO",
|
||||||
|
" No ",
|
||||||
|
]) {
|
||||||
|
expect(checkField(desc, raw)).toEqual({ value: false, error: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkField coerces numeric 1/0 booleans (common in JSON payloads)", () => {
|
||||||
|
const desc = { type: "boolean" as const, optional: true, rules: [] };
|
||||||
|
expect(checkField(desc, 1)).toEqual({ value: true, error: null });
|
||||||
|
expect(checkField(desc, 0)).toEqual({ value: false, error: null });
|
||||||
|
expect(checkField(desc, true)).toEqual({ value: true, error: null });
|
||||||
|
expect(checkField(desc, false)).toEqual({ value: false, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkField rejects unrecognised boolean strings/values as a type error, not a silent false", () => {
|
||||||
|
const desc = { type: "boolean" as const, optional: true, rules: [] };
|
||||||
|
for (const raw of ["yes please", "maybe", "treu", "TRUE!", "2", { a: 1 }, [1, 2]]) {
|
||||||
|
const result = checkField(desc, raw);
|
||||||
|
expect(result.error).toBe("Must be true or false");
|
||||||
|
}
|
||||||
|
const custom = checkField(
|
||||||
|
{ type: "boolean" as const, optional: true, typeMessage: "Pick yes or no", rules: [] },
|
||||||
|
"maybe",
|
||||||
|
);
|
||||||
|
expect(custom.error).toBe("Pick yes or no");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkField treats absent/empty boolean input as false, erroring only when required", () => {
|
||||||
|
const optionalDesc = { type: "boolean" as const, optional: true, rules: [] };
|
||||||
|
expect(checkField(optionalDesc, undefined)).toEqual({ value: false, error: null });
|
||||||
|
expect(checkField(optionalDesc, null)).toEqual({ value: false, error: null });
|
||||||
|
expect(checkField(optionalDesc, "")).toEqual({ value: false, error: null });
|
||||||
|
|
||||||
|
const requiredDesc = {
|
||||||
|
type: "boolean" as const,
|
||||||
|
requiredMessage: "Required",
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
expect(checkField(requiredDesc, undefined).error).toBe("Required");
|
||||||
|
expect(checkField(requiredDesc, "").error).toBe("Required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkField still errors when a required boolean is explicitly false (checkbox semantics locked in)", () => {
|
||||||
|
expect(
|
||||||
|
checkField(
|
||||||
|
{ type: "boolean" as const, requiredMessage: "Accept the terms to continue", rules: [] },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toEqual({ value: false, error: "Accept the terms to continue" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client/server boolean parity: checkField and the browser runtime agree on every case", () => {
|
||||||
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `
|
||||||
|
<form data-schema="parity">
|
||||||
|
<input name="field">
|
||||||
|
<span data-error="field"></span>
|
||||||
|
</form>`;
|
||||||
|
const schema = v.object({ field: v.boolean().optional() }).describe();
|
||||||
|
win.__wrnSchemas = { parity: schema };
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).document = win.document;
|
||||||
|
|
||||||
|
try {
|
||||||
|
(0, eval)(VALIDATE_RUNTIME);
|
||||||
|
const runtime = win.__wrnValidate as { init(root: Document): void };
|
||||||
|
runtime.init(win.document as unknown as Document);
|
||||||
|
const input = win.document.querySelector("input") as HappyDOMHTMLInputElement;
|
||||||
|
const error = win.document.querySelector("[data-error=field]") as HappyDOMHTMLElement;
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
"true",
|
||||||
|
"TRUE",
|
||||||
|
" True ",
|
||||||
|
"on",
|
||||||
|
"ON",
|
||||||
|
"1",
|
||||||
|
" 1 ",
|
||||||
|
"yes",
|
||||||
|
"YES",
|
||||||
|
"false",
|
||||||
|
"FALSE",
|
||||||
|
"off",
|
||||||
|
"OFF",
|
||||||
|
"0",
|
||||||
|
"no",
|
||||||
|
"NO",
|
||||||
|
"",
|
||||||
|
"yes please",
|
||||||
|
"maybe",
|
||||||
|
"treu",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const raw of cases) {
|
||||||
|
const serverResult = checkField(schema.fields.field, raw);
|
||||||
|
input.value = raw;
|
||||||
|
input.dispatchEvent(windowEvent(win, "blur", { bubbles: true }));
|
||||||
|
const clientRejected = error.textContent !== "";
|
||||||
|
expect(clientRejected).toBe(serverResult.error !== null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
delete (globalThis as Record<string, unknown>).window;
|
||||||
|
delete (globalThis as Record<string, unknown>).document;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("invalid() returns a 400 with errors", async () => {
|
test("invalid() returns a 400 with errors", async () => {
|
||||||
const res = invalid({ email: "bad" });
|
const res = invalid({ email: "bad" });
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user