Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0dffa87d | ||
|
|
f57bd05a03 | ||
|
|
2bb52487eb | ||
|
|
990a8128a7 | ||
|
|
7a1b4e5b33 | ||
|
|
aded2daab9 | ||
|
|
6bb3ab5fe7 | ||
|
|
4aa0973352 | ||
|
|
e616ed276e | ||
|
|
74490964ee | ||
|
|
7a3e55b150 | ||
|
|
fb24cc7ec3 | ||
|
|
63316111cb | ||
|
|
0ed8351828 | ||
|
|
890d6106b3 | ||
|
|
de99a2c2e0 | ||
|
|
442c058d0c | ||
|
|
a08dfa5322 | ||
|
|
712a6d3d8c | ||
|
|
9dec811069 | ||
|
|
847b7010d1 | ||
|
|
1dbe16dc93 | ||
|
|
f32b33e3b6 | ||
|
|
768074ac0a | ||
|
|
c7e40154ca | ||
|
|
680ea73975 | ||
|
|
953b1cd692 | ||
|
|
87a00de5f3 | ||
|
|
d069f5ddd7 | ||
|
|
abebbcf8af | ||
|
|
5720f93db1 | ||
|
|
7f5e1bc3cf | ||
|
|
97d60d0ba8 | ||
|
|
e7e7b58160 | ||
|
|
e09a35b4bd | ||
|
|
5c8f3ede54 | ||
|
|
d19595c7ba | ||
|
|
b7796b103a | ||
|
|
ec63090006 | ||
|
|
224af8fd96 | ||
|
|
68cc75d0b0 | ||
|
|
43652c14af | ||
|
|
e40d8319a6 | ||
|
|
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 | ||
|
|
e819c5739e | ||
|
|
ac164789bd | ||
|
|
88ec3a5cb6 | ||
|
|
28085b5a43 | ||
|
|
5d7bd81601 | ||
|
|
5e114d867f | ||
|
|
10421465df | ||
|
|
52cce2c628 | ||
|
|
afa2a8c093 |
@@ -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/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@wrnexus:registry=https://registry.npmjs.org/
|
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||||
audit=true
|
audit=true
|
||||||
fund=false
|
fund=false
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -93,6 +93,25 @@
|
|||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"examples/crm-app": {
|
||||||
|
"name": "wrnexus-crm-example",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/auth": "workspace:*",
|
||||||
|
"@wrnexus/authz": "workspace:*",
|
||||||
|
"@wrnexus/core": "workspace:*",
|
||||||
|
"@wrnexus/db": "workspace:*",
|
||||||
|
"@wrnexus/styles": "workspace:*",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
"examples/i18n-showcase": {
|
"examples/i18n-showcase": {
|
||||||
"name": "i18n-showcase",
|
"name": "i18n-showcase",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
@@ -216,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.12",
|
"version": "0.8.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
@@ -272,7 +291,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.36",
|
"version": "0.8.47",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -287,6 +306,7 @@
|
|||||||
"@wrnexus/mcp": "workspace:*",
|
"@wrnexus/mcp": "workspace:*",
|
||||||
"@wrnexus/playground": "workspace:*",
|
"@wrnexus/playground": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/router": "workspace:*",
|
"@wrnexus/router": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
"@wrnexus/styles": "workspace:*",
|
"@wrnexus/styles": "workspace:*",
|
||||||
@@ -299,9 +319,8 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
@@ -318,18 +337,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 +356,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.33",
|
"version": "0.8.42",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -364,7 +383,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-toolbar": {
|
"packages/dev-toolbar": {
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.10",
|
"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 +416,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 +470,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 +553,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 +641,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 +661,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.8.19",
|
"version": "0.8.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -662,7 +682,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 +987,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 +1409,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=="],
|
||||||
@@ -1395,6 +1425,8 @@
|
|||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
|
"wrnexus-crm-example": ["wrnexus-crm-example@workspace:examples/crm-app"],
|
||||||
|
|
||||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|||||||
@@ -1288,8 +1288,6 @@ wrnexus help | --help | -h
|
|||||||
import type { AppConfig } from "@wrnexus/styles";
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
compatibilityDate: "2026-08-02",
|
|
||||||
frameworkBehaviour: 1,
|
|
||||||
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
||||||
|
|
||||||
seo: { // SeoConfig (global defaults, merged per page)
|
seo: { // SeoConfig (global defaults, merged per page)
|
||||||
|
|||||||
@@ -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
+21
-10
@@ -1040,7 +1040,8 @@
|
|||||||
"rpcManifest",
|
"rpcManifest",
|
||||||
"runtimeCapabilities",
|
"runtimeCapabilities",
|
||||||
"runtimeTypeOf",
|
"runtimeTypeOf",
|
||||||
"serializeIslandProps"
|
"serializeIslandProps",
|
||||||
|
"stripBrowserTypes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/content": {
|
"@wrnexus/content": {
|
||||||
@@ -1083,6 +1084,7 @@
|
|||||||
"BackoffStrategy",
|
"BackoffStrategy",
|
||||||
"Bucket",
|
"Bucket",
|
||||||
"BudgetViolation",
|
"BudgetViolation",
|
||||||
|
"BuiltApiRequest",
|
||||||
"Bulkhead",
|
"Bulkhead",
|
||||||
"BulkheadOptions",
|
"BulkheadOptions",
|
||||||
"CSRF_COOKIE",
|
"CSRF_COOKIE",
|
||||||
@@ -1203,6 +1205,7 @@
|
|||||||
"UploadScanner",
|
"UploadScanner",
|
||||||
"assertTenantAccess",
|
"assertTenantAccess",
|
||||||
"bridgeRealtime",
|
"bridgeRealtime",
|
||||||
|
"buildApiRequest",
|
||||||
"cacheControl",
|
"cacheControl",
|
||||||
"checkPerformanceBudgets",
|
"checkPerformanceBudgets",
|
||||||
"collectUploads",
|
"collectUploads",
|
||||||
@@ -1228,6 +1231,7 @@
|
|||||||
"escapeHtml",
|
"escapeHtml",
|
||||||
"etag",
|
"etag",
|
||||||
"executionContextFromHttp",
|
"executionContextFromHttp",
|
||||||
|
"getRequestContext",
|
||||||
"getUser",
|
"getUser",
|
||||||
"hashPassword",
|
"hashPassword",
|
||||||
"isRoomDefinition",
|
"isRoomDefinition",
|
||||||
@@ -1259,9 +1263,11 @@
|
|||||||
"requestId",
|
"requestId",
|
||||||
"requestLogger",
|
"requestLogger",
|
||||||
"requireAuth",
|
"requireAuth",
|
||||||
|
"requireRequestContext",
|
||||||
"requireTenant",
|
"requireTenant",
|
||||||
"resilientCall",
|
"resilientCall",
|
||||||
"resolveRequestUrl",
|
"resolveRequestUrl",
|
||||||
|
"runWithRequestContext",
|
||||||
"sanitizeFilename",
|
"sanitizeFilename",
|
||||||
"saveUpload",
|
"saveUpload",
|
||||||
"saveUploadSecure",
|
"saveUploadSecure",
|
||||||
@@ -1464,6 +1470,7 @@
|
|||||||
"startServer",
|
"startServer",
|
||||||
"toRequest",
|
"toRequest",
|
||||||
"validateRpcCsrf",
|
"validateRpcCsrf",
|
||||||
|
"withServerFnRequestContext",
|
||||||
"writeResponse"
|
"writeResponse"
|
||||||
],
|
],
|
||||||
"./serve-entry": []
|
"./serve-entry": []
|
||||||
@@ -1674,6 +1681,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1718,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
@@ -1831,7 +1840,7 @@
|
|||||||
"Position",
|
"Position",
|
||||||
"Range",
|
"Range",
|
||||||
"TextDocument",
|
"TextDocument",
|
||||||
"WRN_COMPLETIONS",
|
"WRN_KEYWORDS",
|
||||||
"WorkspaceCompletionItem",
|
"WorkspaceCompletionItem",
|
||||||
"clearWorkspaceIndexCache",
|
"clearWorkspaceIndexCache",
|
||||||
"completionItems",
|
"completionItems",
|
||||||
@@ -1852,7 +1861,11 @@
|
|||||||
"workspaceCompletionItems",
|
"workspaceCompletionItems",
|
||||||
"workspaceSymbolLocations"
|
"workspaceSymbolLocations"
|
||||||
],
|
],
|
||||||
"./server": []
|
"./server": [
|
||||||
|
"ApiCallCompletionItem",
|
||||||
|
"apiCallCompletions",
|
||||||
|
"apiCallHover"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/mcp": {
|
"@wrnexus/mcp": {
|
||||||
".": [
|
".": [
|
||||||
@@ -2630,10 +2643,6 @@
|
|||||||
"BrowserCookiePreference",
|
"BrowserCookiePreference",
|
||||||
"BrowserCookiesConfig",
|
"BrowserCookiesConfig",
|
||||||
"BuildConfig",
|
"BuildConfig",
|
||||||
"CURRENT_COMPATIBILITY_DATE",
|
|
||||||
"CURRENT_FRAMEWORK_BEHAVIOUR",
|
|
||||||
"CompatibilityPolicy",
|
|
||||||
"CompatibilityReport",
|
|
||||||
"ConfigIssue",
|
"ConfigIssue",
|
||||||
"ContrastResult",
|
"ContrastResult",
|
||||||
"CssPerformanceAuditIssue",
|
"CssPerformanceAuditIssue",
|
||||||
@@ -2684,7 +2693,6 @@
|
|||||||
"findStyleEntry",
|
"findStyleEntry",
|
||||||
"fontCspSources",
|
"fontCspSources",
|
||||||
"headToString",
|
"headToString",
|
||||||
"isCompatibilityDate",
|
|
||||||
"loadAppConfig",
|
"loadAppConfig",
|
||||||
"loadEnv",
|
"loadEnv",
|
||||||
"loadRawConfig",
|
"loadRawConfig",
|
||||||
@@ -2697,7 +2705,6 @@
|
|||||||
"renderThemeRuntime",
|
"renderThemeRuntime",
|
||||||
"resolveAccentName",
|
"resolveAccentName",
|
||||||
"resolveBrowserCookieOptions",
|
"resolveBrowserCookieOptions",
|
||||||
"resolveCompatibility",
|
|
||||||
"resolveConfigLayers",
|
"resolveConfigLayers",
|
||||||
"resolveProfile",
|
"resolveProfile",
|
||||||
"resolveThemeConfig",
|
"resolveThemeConfig",
|
||||||
@@ -2785,6 +2792,7 @@
|
|||||||
"parseStructuredImports",
|
"parseStructuredImports",
|
||||||
"positionAt",
|
"positionAt",
|
||||||
"runtimeTypeOf",
|
"runtimeTypeOf",
|
||||||
|
"skipLiteralOrComment",
|
||||||
"sliceSource",
|
"sliceSource",
|
||||||
"stripRuntimeFunctionModifiers",
|
"stripRuntimeFunctionModifiers",
|
||||||
"supportsSyntaxFeature",
|
"supportsSyntaxFeature",
|
||||||
@@ -2851,7 +2859,10 @@
|
|||||||
"LexError",
|
"LexError",
|
||||||
"Lexer",
|
"Lexer",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenType"
|
"TokenType",
|
||||||
|
"isIdentPart",
|
||||||
|
"isIdentStart",
|
||||||
|
"skipLiteralOrComment"
|
||||||
],
|
],
|
||||||
"./types": [
|
"./types": [
|
||||||
"RuntimeType",
|
"RuntimeType",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
**New package `packages/react/`:**
|
**New package `packages/react/`:**
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
| ------------------------ | ------------------------------------------------------------------ |
|
||||||
| `package.json` | Package manifest; optional peer deps |
|
| `package.json` | Package manifest; optional peer deps |
|
||||||
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
||||||
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
**Modified:**
|
**Modified:**
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
| ------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
||||||
| `packages/compiler/src/island-codegen.ts` *(new)* | Marker emission, props serialization, diagnostics |
|
| `packages/compiler/src/island-codegen.ts` _(new)_ | Marker emission, props serialization, diagnostics |
|
||||||
| `packages/compiler/src/island-bundle.ts` *(new)* | Island entry generation + `Bun.build` with shared React chunk |
|
| `packages/compiler/src/island-bundle.ts` _(new)_ | Island entry generation + `Bun.build` with shared React chunk |
|
||||||
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
||||||
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
||||||
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
||||||
@@ -58,11 +58,13 @@
|
|||||||
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/package.json`
|
- Create: `packages/react/package.json`
|
||||||
- Create: `packages/react/src/snapshot-cache.ts`
|
- Create: `packages/react/src/snapshot-cache.ts`
|
||||||
- Test: `packages/react/test/snapshot-cache.test.ts`
|
- Test: `packages/react/test/snapshot-cache.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing.
|
- Consumes: nothing.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
||||||
@@ -193,9 +195,7 @@ export interface SnapshotCache<S extends object> {
|
|||||||
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
||||||
* `readonlySnapshot` does by design.
|
* `readonlySnapshot` does by design.
|
||||||
*/
|
*/
|
||||||
export function createSnapshotCache<S extends object>(
|
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||||
source: SnapshotSource<S>,
|
|
||||||
): SnapshotCache<S> {
|
|
||||||
let cached: Readonly<S> | undefined;
|
let cached: Readonly<S> | undefined;
|
||||||
let dirty = true;
|
let dirty = true;
|
||||||
|
|
||||||
@@ -259,12 +259,14 @@ git commit -m "feat(react): add referentially-stable snapshot and selector cache
|
|||||||
### Task 2: Store bridge hook
|
### Task 2: Store bridge hook
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/store-bridge.ts`
|
- Create: `packages/react/src/store-bridge.ts`
|
||||||
- Create: `packages/react/src/index.ts`
|
- Create: `packages/react/src/index.ts`
|
||||||
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
||||||
- Test: `packages/react/test/store-bridge.test.ts`
|
- Test: `packages/react/test/store-bridge.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
||||||
@@ -436,10 +438,12 @@ git commit -m "feat(react): add useWrnStore bridge over useSyncExternalStore"
|
|||||||
### Task 3: Island marker codegen
|
### Task 3: Island marker codegen
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-codegen.ts`
|
- Create: `packages/compiler/src/island-codegen.ts`
|
||||||
- Test: `packages/compiler/test/island-codegen.test.ts`
|
- Test: `packages/compiler/test/island-codegen.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
||||||
@@ -596,10 +600,12 @@ git commit -m "feat(compiler): add island marker codegen and props contract"
|
|||||||
### Task 4: Resolve `.tsx` imports as islands
|
### Task 4: Resolve `.tsx` imports as islands
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/import-resolver.ts`
|
- Modify: `packages/compiler/src/import-resolver.ts`
|
||||||
- Test: `packages/compiler/test/island-resolution.test.ts`
|
- Test: `packages/compiler/test/island-resolution.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
||||||
|
|
||||||
@@ -735,10 +741,12 @@ git commit -m "feat(compiler): resolve .tsx imports and tag them as islands"
|
|||||||
### Task 5: Island error boundary
|
### Task 5: Island error boundary
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/error-boundary.tsx`
|
- Create: `packages/react/src/error-boundary.tsx`
|
||||||
- Test: `packages/react/test/error-boundary.test.tsx`
|
- Test: `packages/react/test/error-boundary.test.tsx`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
||||||
|
|
||||||
@@ -857,11 +865,13 @@ git commit -m "feat(react): add per-island error boundary"
|
|||||||
### Task 6: Island runtime — mount, strategies, unmount
|
### Task 6: Island runtime — mount, strategies, unmount
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/island-runtime.ts`
|
- Create: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-runtime.test.ts`
|
- Test: `packages/react/test/island-runtime.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
@@ -1070,10 +1080,12 @@ git commit -m "feat(react): add island mount strategies and navigation-safe unmo
|
|||||||
### Task 7: Island bundling with a shared React chunk
|
### Task 7: Island bundling with a shared React chunk
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-bundle.ts`
|
- Create: `packages/compiler/src/island-bundle.ts`
|
||||||
- Test: `packages/compiler/test/island-bundle.test.ts`
|
- Test: `packages/compiler/test/island-bundle.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
||||||
@@ -1226,6 +1238,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
### Task 8: Serve island assets in dev, prod, and static build
|
### Task 8: Serve island assets in dev, prod, and static build
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/runtime-source.ts`
|
- Create: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/dev-server/src/assets.ts`
|
- Modify: `packages/dev-server/src/assets.ts`
|
||||||
- Modify: `packages/dev-server/src/prod.ts`
|
- Modify: `packages/dev-server/src/prod.ts`
|
||||||
@@ -1233,6 +1246,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
- Test: `packages/react/test/runtime-source.test.ts`
|
- Test: `packages/react/test/runtime-source.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
||||||
|
|
||||||
@@ -1375,10 +1389,12 @@ git commit -m "feat(islands): serve the island runtime in dev, prod, and static
|
|||||||
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/analysis.ts`
|
- Modify: `packages/compiler/src/analysis.ts`
|
||||||
- Test: `packages/compiler/test/island-classification.test.ts`
|
- Test: `packages/compiler/test/island-classification.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `ResolvedImport.kind` (Task 4).
|
- Consumes: `ResolvedImport.kind` (Task 4).
|
||||||
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
||||||
|
|
||||||
@@ -1457,10 +1473,12 @@ git commit -m "feat(compiler): classify island routes as static-interactive"
|
|||||||
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
||||||
- Test: `packages/compiler/test/island-integration.test.ts`
|
- Test: `packages/compiler/test/island-integration.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
||||||
- Produces: nothing consumed downstream.
|
- Produces: nothing consumed downstream.
|
||||||
|
|
||||||
@@ -1493,10 +1511,7 @@ import { join, resolve } from "node:path";
|
|||||||
import { buildIslands } from "../src/island-bundle.ts";
|
import { buildIslands } from "../src/island-bundle.ts";
|
||||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||||
|
|
||||||
const COUNTER = resolve(
|
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||||
import.meta.dir,
|
|
||||||
"../../../examples/basic-app/app/islands/Counter.tsx",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||||
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
||||||
@@ -1522,8 +1537,8 @@ test("a page with multiple islands ships React exactly once", async () => {
|
|||||||
.filter((file) => file.endsWith(".js"))
|
.filter((file) => file.endsWith(".js"))
|
||||||
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
||||||
|
|
||||||
const withReactInternals = bundles.filter((source) =>
|
const withReactInternals = bundles.filter(
|
||||||
source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
(source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.assets).toHaveLength(2);
|
expect(result.assets).toHaveLength(2);
|
||||||
@@ -1564,12 +1579,14 @@ git commit -m "test(islands): guard zero-JS routes and single-React bundling"
|
|||||||
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/render-phase.ts`
|
- Create: `packages/react/src/render-phase.ts`
|
||||||
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/render-phase.test.ts`
|
- Test: `packages/react/test/render-phase.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `useWrnActions` (Task 2).
|
- Consumes: `useWrnActions` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `beginRenderPhase(): void`
|
- `beginRenderPhase(): void`
|
||||||
@@ -1730,12 +1747,14 @@ git commit -m "feat(react): fail loudly on store writes during island render"
|
|||||||
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/react/src/island-runtime.ts`
|
- Modify: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/runtime-source.ts`
|
- Modify: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-hmr.test.ts`
|
- Test: `packages/react/test/island-hmr.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
|
|
||||||
@@ -1751,8 +1770,7 @@ import { islandRootCount, mountIslands, remountIslands } from "../src/island-run
|
|||||||
|
|
||||||
test("remount replaces island output without leaking roots", async () => {
|
test("remount replaces island output without leaking roots", async () => {
|
||||||
const window = new Window();
|
const window = new Window();
|
||||||
window.document.body.innerHTML =
|
window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
||||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
|
||||||
data-wrn-island-props='{}'></div>`;
|
data-wrn-island-props='{}'></div>`;
|
||||||
(globalThis as any).window = window;
|
(globalThis as any).window = window;
|
||||||
(globalThis as any).document = window.document;
|
(globalThis as any).document = window.document;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
|||||||
|
# Editor Tooling Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The language server and VS Code extension understand `apis { }`, complete `api.<name>()`, flag the removed constructs, and stop offering syntax the compiler rejects.
|
||||||
|
|
||||||
|
**Architecture:** Four surfaces change independently — the TextMate grammar, the keyword and snippet completions, the server's call completion and hover, and diagnostics for removed constructs. A final task answers by observation whether generated type errors surface inside `.wrn`, which has never been verified.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun, TypeScript, `bun:test`, `node --test`, LSP, TextMate grammars.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-19-editor-tooling-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `client` is one word with several jobs: `client state { }`, `runtime = "client"`, and the `client function` modifier all survive. **Only the `client { }` / `ssr { }` data-block patterns are removed.** Blanket removal would un-highlight constructs that still exist.
|
||||||
|
- Offering a construct the compiler rejects is worse than offering nothing.
|
||||||
|
- **Nothing may claim inline diagnostics work until someone has seen them work.**
|
||||||
|
- The editor bundles embed the compiler and language server — rebuild with `bun run --cwd editors/vscode build`, or the `check:editor-*` gates fail on a stale bundle.
|
||||||
|
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||||
|
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Grammar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `editors/vscode/syntaxes/wrn.tmLanguage.json`
|
||||||
|
- Test: `editors/vscode/test/grammar-apis.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `apis` highlights as a block keyword; entries highlight as declarations.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `editors/vscode/test/grammar-apis.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { readFileSync } = require("node:fs");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
|
||||||
|
const grammar = readFileSync(join(__dirname, "../syntaxes/wrn.tmLanguage.json"), "utf8");
|
||||||
|
|
||||||
|
test("the grammar knows the apis block", () => {
|
||||||
|
assert.ok(grammar.includes("apis"), "apis should appear as a block keyword");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client keeps its highlighting where it is still valid", () => {
|
||||||
|
// client state {}, runtime = "client", and client function all survive.
|
||||||
|
// Only the client {} data block was removed.
|
||||||
|
assert.ok(grammar.includes("client"), "client must still be matched");
|
||||||
|
assert.ok(grammar.includes("shared"), "the shared function modifier must still be matched");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||||
|
Expected: FAIL on the first assertion — `apis` is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `apis`, remove only the data-block patterns**
|
||||||
|
|
||||||
|
Add `apis` to the block-keyword pattern alongside `functions`. Then find the patterns matching `ssr`/`client` as **data blocks** and remove only those. Leave every rule that matches `client` in `client state`, in `runtime` values, and as a function modifier.
|
||||||
|
|
||||||
|
Add a pattern for an entry — `<name> <METHOD> <path>` — so a declaration reads as a declaration.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test and check by eye**
|
||||||
|
|
||||||
|
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||||
|
Expected: PASS. Then open a `.wrn` file using `apis { }`, `client state { }`, `functions { shared function }`, and `runtime = "client"` in VS Code and confirm each still colours correctly. Record what you saw.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add editors/vscode
|
||||||
|
git commit -m "feat(editor): highlight the apis block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Keyword and snippet completion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/language-server/src/index.ts` (`WRN_KEYWORDS`, ~line 31)
|
||||||
|
- Modify: `editors/vscode/src/completion.js` (block snippets)
|
||||||
|
- Test: `packages/language-server/test/apis-completion.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: nothing.
|
||||||
|
- Produces: `apis` is a known keyword; `ssr` / `client` data-block snippets are gone.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/language-server/test/apis-completion.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { WRN_KEYWORDS } from "../src/index.ts";
|
||||||
|
|
||||||
|
test("apis is a known page-level keyword", () => {
|
||||||
|
expect(WRN_KEYWORDS).toContain("apis");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server/test/apis-completion.test.ts`
|
||||||
|
Expected: FAIL — `apis` is missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the keyword and the snippets**
|
||||||
|
|
||||||
|
Add `"apis"` to `WRN_KEYWORDS`. In `editors/vscode/src/completion.js`, add a container snippet and an entry snippet including the `request` / `response` / `error` sections, and remove any `ssr {` / `client {` data-block snippet.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server && bun run --cwd editors/vscode test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/language-server editors/vscode
|
||||||
|
git commit -m "feat(editor): complete the apis block and drop the removed snippets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `api.` call completion and hover
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/language-server/src/server.ts` (completion and hover handlers)
|
||||||
|
- Test: `packages/language-server/test/api-call-completion.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `ast.dataApis` entries, which carry `name`, `method`, `path`, and `sections`.
|
||||||
|
- Produces: completion items for `api.` and hover detail for a block name.
|
||||||
|
|
||||||
|
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor should know it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/language-server/test/api-call-completion.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { apiCallCompletions, apiCallHover } from "../src/server.ts";
|
||||||
|
|
||||||
|
const SOURCE = `page Search {
|
||||||
|
apis {
|
||||||
|
searchUsers POST /api/users {
|
||||||
|
request { body { name?: string } }
|
||||||
|
response { return data.users }
|
||||||
|
}
|
||||||
|
|
||||||
|
listTeams GET /api/teams {
|
||||||
|
response { return data.teams }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function go(): Promise<void> {
|
||||||
|
await api.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("api. offers every declared block with method and path", () => {
|
||||||
|
const items = apiCallCompletions(SOURCE);
|
||||||
|
const labels = items.map((item) => item.label);
|
||||||
|
|
||||||
|
expect(labels).toContain("searchUsers");
|
||||||
|
expect(labels).toContain("listTeams");
|
||||||
|
|
||||||
|
const search = items.find((item) => item.label === "searchUsers")!;
|
||||||
|
expect(search.detail).toContain("POST");
|
||||||
|
expect(search.detail).toContain("/api/users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hovering a block name reports its method, path and request fields", () => {
|
||||||
|
const hover = apiCallHover(SOURCE, "searchUsers");
|
||||||
|
|
||||||
|
expect(hover).toContain("POST");
|
||||||
|
expect(hover).toContain("/api/users");
|
||||||
|
expect(hover).toContain("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a page with no apis block offers nothing", () => {
|
||||||
|
expect(apiCallCompletions(`page P { view { <main>x</main> } }`)).toEqual([]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server/test/api-call-completion.test.ts`
|
||||||
|
Expected: FAIL — the functions do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement and export both functions**
|
||||||
|
|
||||||
|
Add `apiCallCompletions(source: string)` and `apiCallHover(source: string, name: string)` to `packages/language-server/src/server.ts`, parsing with `@wrnexus/syntax` and reading `ast.dataApis`. Wire `apiCallCompletions` into the `textDocument/completion` handler for positions immediately after `api.`, and `apiCallHover` into `textDocument/hover`.
|
||||||
|
|
||||||
|
The parser must tolerate the half-typed `await api.` in the fixture. If it throws, fall back to returning `[]` rather than failing the request — completion fires while the document does not parse, which is the normal case.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/language-server
|
||||||
|
git commit -m "feat(language-server): complete and describe api block calls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: The `api=` attribute in markup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/language-server/src/html-service.ts`
|
||||||
|
- Test: `packages/language-server/test/api-attribute.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `apiCallCompletions` from Task 3.
|
||||||
|
- Produces: `api="…"` is not flagged as unknown, and completion inside the quotes offers block names.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/language-server/test/api-attribute.test.ts` asserting that (a) an `api="searchUsers"` attribute produces no unknown-attribute diagnostic, and (b) completion inside the quotes offers `searchUsers`. Reuse the fixture shape from Task 3.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server/test/api-attribute.test.ts`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Teach the HTML service about the attribute**
|
||||||
|
|
||||||
|
Treat `api` as a known attribute on any element, and route completion inside its quotes to `apiCallCompletions`. The value is a call expression, not text — it must not be spell-checked or reformatted as prose.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test packages/language-server
|
||||||
|
bun run format
|
||||||
|
git add packages/language-server
|
||||||
|
git commit -m "feat(language-server): understand the api binding attribute"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Diagnostics for the removed constructs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/language-server/src/diagnostics` entry point (wherever `.wrn` diagnostics are produced)
|
||||||
|
- Test: `packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: an `ssr { api … }` or `client { api … }` block yields a diagnostic naming `apis { }`, positioned on the block keyword.
|
||||||
|
|
||||||
|
The compiler already rejects these. The editor should say so while typing, and say what to do instead.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/language-server/test/removed-construct-diagnostics.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { diagnoseWrn } from "../src/index.ts";
|
||||||
|
|
||||||
|
test("an ssr data block is flagged and names the replacement", () => {
|
||||||
|
const diagnostics = diagnoseWrn(`page P {
|
||||||
|
ssr { api x GET /api/x { response { return data } } }
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(diagnostics.length).toBeGreaterThan(0);
|
||||||
|
expect(diagnostics[0]!.message).toContain("apis");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client state is not flagged", () => {
|
||||||
|
const diagnostics = diagnoseWrn(`page P {
|
||||||
|
client state { count = 0 }
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use whichever diagnostic entry point the language server exports; keep the assertions identical.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||||
|
Expected: FAIL — either no diagnostic, or one that does not name `apis`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Surface the parse error as a diagnostic**
|
||||||
|
|
||||||
|
The parser already throws a message naming `apis { }` for these blocks. Ensure that message reaches the diagnostic with a position on the offending keyword rather than at offset zero.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test packages/language-server
|
||||||
|
bun run format
|
||||||
|
git add packages/language-server
|
||||||
|
git commit -m "feat(language-server): flag the removed data blocks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Answer the inline-diagnostics question, then the full gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: whatever the observation in Step 2 shows is needed, or none
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Tasks 1-5.
|
||||||
|
|
||||||
|
The `apis` plan generates type assertions that make `tsc` fail when a block declares a field its endpoint rejects. **Whether that failure appears inside the `.wrn` file has never been confirmed** — it was inferred from reading source. This task settles it by looking.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rebuild the bundles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd editors/vscode build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Observe, and write down what you see**
|
||||||
|
|
||||||
|
In `examples/basic-app`, add a field to an `apis { }` entry that its endpoint does not accept, and run `bun run --cwd examples/basic-app wrnexus generate types`. Open the page in VS Code and record exactly where the error appears: on the block, only in `app/types/wrnexus.generated.api-checks.ts`, or nowhere.
|
||||||
|
|
||||||
|
Write the answer into the task report. **Do not skip this step and reason about it instead** — that is what left the question open the first time.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Act on what you observed**
|
||||||
|
|
||||||
|
If the error already surfaces usefully on the block, document it and stop.
|
||||||
|
|
||||||
|
If it appears only in the generated file, map the diagnostic back: the generator knows which page and block produced each assertion, so record that mapping when emitting and use it to relocate the diagnostic.
|
||||||
|
|
||||||
|
If that mapping proves larger than this task can hold, **stop and report it as follow-up work** rather than half-building it. Say so plainly in the report.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove the temporary field**
|
||||||
|
|
||||||
|
Revert the deliberate error and confirm `bun run typecheck` passes with zero net diff in `examples/basic-app`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full gate**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
bun test
|
||||||
|
bun run typecheck
|
||||||
|
bun run --cwd editors/vscode build
|
||||||
|
bun run --cwd editors/vscode test
|
||||||
|
bun run check:production
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit 0 throughout, including `check:editor-compiler`, `check:editor-language-server`, and `check:editor-extension`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual pass, recorded**
|
||||||
|
|
||||||
|
Open the migrated `examples/basic-app` in VS Code and confirm: `apis { }` highlights, `api.` completes with the page's block names, hovering a name shows its method and path, and an `ssr { api … }` block is flagged. Record what you saw in the report — including anything that did not work.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(editor): complete tooling support for the apis block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
|
||||||
|
- **`client` is not one thing.** Removing every `client` rule from the grammar would break `client state`, `runtime = "client"`, and `client function`. Only the data-block patterns go.
|
||||||
|
- **The parser must tolerate half-typed input.** Completion fires while the document does not parse; a thrown error must become an empty completion list, not a failed request.
|
||||||
|
- **Step 2 of Task 6 is an observation, not a deduction.** Open the editor and look.
|
||||||
|
- **If a test would still pass with the code it guards deleted, it is not a test.**
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
# Legacy and Config Cleanup Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Delete the compatibility-flag surface, the `"legacy"` function runtime, dead migrations, and deprecated APIs before the framework's first public release.
|
||||||
|
|
||||||
|
**Architecture:** Almost all of this is deletion. Seven config keys are never read by any code, so removing them changes nothing. The one behaviour-sensitive item is the `"legacy"` function runtime, which is mapped to `"shared"` — an equivalent substitution, because an unmarked function is already emitted into both bundles.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun, TypeScript, `bun:test`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-19-legacy-and-config-cleanup-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- This plan removes configuration. It adds none.
|
||||||
|
- The legacy `api` block forms (bare-body `with ($data)`, and `api` inside `ssr {}` / `client {}`) are **out of scope** — they are replaced by the next plan, not deleted here.
|
||||||
|
- A removed config key must be **rejected loudly**, not silently ignored. Someone with a stale config must be told, not left believing a flag still applies.
|
||||||
|
- `bun run format` before every commit; the repo gate is `bun run check:production`.
|
||||||
|
- The editor bundles embed the compiler — rebuild with `bun run --cwd editors/vscode build` after any `packages/syntax` or `packages/compiler` change, or `check:editor-compiler` fails on a stale bundle.
|
||||||
|
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files; escaping mangles them silently. Use file editing tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Replace the `"legacy"` function runtime with `"shared"`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/syntax/src/v060.ts` (the `FunctionRuntime` type; the default at ~line 209)
|
||||||
|
- Modify: `packages/compiler/src/client-codegen.ts` (membership tests at ~lines 173 and 340)
|
||||||
|
- Modify: `packages/compiler/src/server-codegen.ts` (the `["legacy", "server", "shared"]` list)
|
||||||
|
- Modify: `packages/compiler/src/codegen.ts` (`targetFunctions`, ~line 1310)
|
||||||
|
- Test: `packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `FunctionRuntime` becomes `"client" | "server" | "shared"`. Later tasks and plans rely on `"legacy"` no longer existing.
|
||||||
|
|
||||||
|
**Why this is equivalent, not a behaviour change:** an unmarked `function foo()` currently parses as `"legacy"`, and both codegens include `"legacy"` in their membership tests — `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]` for the server. So an unmarked function is already emitted into _both_ bundles, exactly like `shared`. `legacyDefaultRuntime` looks like it should modulate this but is never read.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/compiler/test/legacy-runtime-removal.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generateTargets } from "../src/targets.ts";
|
||||||
|
|
||||||
|
const SOURCE = `page Probe {
|
||||||
|
functions {
|
||||||
|
function unmarkedHelper() {
|
||||||
|
return "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
client function clientOnly() {
|
||||||
|
return "browser";
|
||||||
|
}
|
||||||
|
|
||||||
|
server function serverOnly() {
|
||||||
|
return "server";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("an unmarked function is emitted into both the browser and server modules", () => {
|
||||||
|
// This is the property the "legacy" runtime provided. Removing the variant
|
||||||
|
// must not change it.
|
||||||
|
const targets = generateTargets(parse(SOURCE));
|
||||||
|
|
||||||
|
expect(targets.browser).toContain("unmarkedHelper");
|
||||||
|
expect(targets.server).toContain("unmarkedHelper");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marked functions still go only where they belong", () => {
|
||||||
|
const targets = generateTargets(parse(SOURCE));
|
||||||
|
|
||||||
|
expect(targets.browser).toContain("clientOnly");
|
||||||
|
expect(targets.browser).not.toContain("serverOnly");
|
||||||
|
expect(targets.server).toContain("serverOnly");
|
||||||
|
expect(targets.server).not.toContain("clientOnly");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no emitted target mentions the removed legacy runtime", () => {
|
||||||
|
const targets = generateTargets(parse(SOURCE));
|
||||||
|
|
||||||
|
expect(targets.browser).not.toContain('"legacy"');
|
||||||
|
expect(targets.server).not.toContain('"legacy"');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test and record the baseline**
|
||||||
|
|
||||||
|
Run: `bun test packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||||
|
Expected: the first two tests PASS (they describe current behaviour and must keep passing), the third may already pass. This test file is a **regression guard written before the change**, so a green run here is correct — record the output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the `"legacy"` variant from the type and parser**
|
||||||
|
|
||||||
|
In `packages/syntax/src/v060.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type FunctionRuntime = "client" | "server" | "shared";
|
||||||
|
```
|
||||||
|
|
||||||
|
And at the parse site (~line 209), change the default:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let runtime: FunctionRuntime = "shared";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Drop `"legacy"` from every membership test**
|
||||||
|
|
||||||
|
In `packages/compiler/src/client-codegen.ts`, both occurrences:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
["client", "shared"].includes(fn.runtime),
|
||||||
|
```
|
||||||
|
|
||||||
|
In `packages/compiler/src/server-codegen.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const names = ast.runtimeFunctions
|
||||||
|
.filter((fn) => ["server", "shared"].includes(fn.runtime))
|
||||||
|
.map((fn) => fn.name);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `packages/compiler/src/codegen.ts`, `targetFunctions`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const runtimes =
|
||||||
|
target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const);
|
||||||
|
```
|
||||||
|
|
||||||
|
Search the repo for any remaining `"legacy"` in these packages and remove each — the string must not survive in `packages/syntax` or `packages/compiler`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/syntax packages/compiler`
|
||||||
|
Expected: PASS, including the three guards from Step 1. If the first two now fail, the substitution was not equivalent — stop and report rather than adjusting the test.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Rebuild the editor bundles and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
bun run --cwd editors/vscode build
|
||||||
|
git add packages/syntax packages/compiler editors/vscode/src
|
||||||
|
git commit -m "refactor: replace the legacy function runtime with shared"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Delete the compatibility surface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Delete: `packages/styles/src/compatibility.ts`
|
||||||
|
- Delete: `packages/cli/src/compatibility-command.ts`
|
||||||
|
- Delete: `packages/cli/test/compatibility.test.ts`
|
||||||
|
- Modify: `packages/styles/src/config.ts` (`FunctionsConfig` ~233, `CompatibilityConfig` ~242, `AppConfig extends CompatibilityPolicy` ~249, the `functions?:` and `compatibility?:` members, the `resolveCompatibility` validation ~619, and the `CompatibilityPolicy` import ~23)
|
||||||
|
- Modify: `packages/styles/src/index.ts` (the `./compatibility.ts` exports at ~lines 39-45)
|
||||||
|
- Modify: `packages/cli/src/index.ts` (dispatch at ~line 295, help text at ~line 75)
|
||||||
|
- Modify: `packages/cli/src/create.ts` (~lines 263, 278-283)
|
||||||
|
- Modify: `packages/cli/src/update.ts` (the config insertion string at ~line 389)
|
||||||
|
- Modify: `packages/styles/test/config.test.ts` (assertions on the removed keys)
|
||||||
|
- Modify: `examples/basic-app/wrnexus.config.ts`
|
||||||
|
- Test: `packages/styles/test/removed-config-keys.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: nothing from Task 1.
|
||||||
|
- Produces: `AppConfig` no longer extends `CompatibilityPolicy` and has no `compatibility` or `functions` members. `@wrnexus/styles` no longer exports `resolveCompatibility`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, or `CompatibilityReport`.
|
||||||
|
|
||||||
|
**These seven keys are never read.** `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `legacyDefaultRuntime` appear only in the type declaration, `create.ts`, and `update.ts`. `compatibilityDate` and `frameworkBehaviour` feed only a printed report and one validation. Removing them changes no behaviour.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/styles/test/removed-config-keys.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { validateConfig } from "../src/config.ts";
|
||||||
|
|
||||||
|
// A stale config must fail loudly. Silently ignoring a removed key leaves
|
||||||
|
// someone believing a flag still applies.
|
||||||
|
const REMOVED = [
|
||||||
|
{ key: "compatibilityDate", config: { compatibilityDate: "2026-08-02" } },
|
||||||
|
{ key: "frameworkBehaviour", config: { frameworkBehaviour: 1 } },
|
||||||
|
{ key: "functions", config: { functions: { legacyDefaultRuntime: "current" } } },
|
||||||
|
{ key: "compatibility", config: { compatibility: { legacyEmit: false } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, config } of REMOVED) {
|
||||||
|
test(`a config still setting "${key}" is rejected with a message naming it`, () => {
|
||||||
|
const issues = validateConfig(config as never);
|
||||||
|
const match = issues.find((issue) => issue.path === key || issue.path.startsWith(`${key}.`));
|
||||||
|
|
||||||
|
expect(match).toBeDefined();
|
||||||
|
expect(match!.severity).toBe("error");
|
||||||
|
expect(match!.message.toLowerCase()).toContain("removed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a config without those keys is accepted", () => {
|
||||||
|
const issues = validateConfig({} as never);
|
||||||
|
|
||||||
|
expect(issues.filter((issue) => issue.severity === "error")).toEqual([]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `validateConfig` is not the exported name in `packages/styles/src/config.ts`, use whichever function that module exports for validation and keep the assertions identical.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/styles/test/removed-config-keys.test.ts`
|
||||||
|
Expected: FAIL — the keys are currently accepted, so no issue is produced.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete the compatibility module and its command**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm packages/styles/src/compatibility.ts packages/cli/src/compatibility-command.ts packages/cli/test/compatibility.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
In `packages/styles/src/index.ts`, remove the whole `./compatibility.ts` export block (both the value exports and the `export type` line).
|
||||||
|
|
||||||
|
In `packages/cli/src/index.ts`, remove the `case "compatibility":` dispatch and the `wrnexus compatibility …` line from the help text.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove the config members and add the rejections**
|
||||||
|
|
||||||
|
In `packages/styles/src/config.ts`: delete the `CompatibilityPolicy` import, the `FunctionsConfig` and `CompatibilityConfig` interfaces, the `functions?:` and `compatibility?:` members of `AppConfig`, `extends CompatibilityPolicy` on `AppConfig`, and the `resolveCompatibility` validation block.
|
||||||
|
|
||||||
|
Then add the rejections so a stale config fails loudly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const REMOVED_CONFIG_KEYS = [
|
||||||
|
"compatibilityDate",
|
||||||
|
"frameworkBehaviour",
|
||||||
|
"functions",
|
||||||
|
"compatibility",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const key of REMOVED_CONFIG_KEYS) {
|
||||||
|
if ((config as Record<string, unknown>)[key] !== undefined) {
|
||||||
|
issues.push({
|
||||||
|
path: key,
|
||||||
|
severity: "error",
|
||||||
|
message: "was removed; delete it from the configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Place this beside the other validation pushes, using whatever local variable that function accumulates issues in.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Stop scaffolding and inserting the keys**
|
||||||
|
|
||||||
|
In `packages/cli/src/create.ts`, delete the `compatibilityDate`, `frameworkBehaviour`, and `functions: { legacyDefaultRuntime: … }` lines from the generated config.
|
||||||
|
|
||||||
|
In `packages/cli/src/update.ts` (~line 389), remove `functions: { legacyDefaultRuntime: "current" },` and the whole `compatibility: { … },` fragment from the insertion string.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Trim the example app config**
|
||||||
|
|
||||||
|
In `examples/basic-app/wrnexus.config.ts`, delete `compatibilityDate`, `frameworkBehaviour`, `functions`, and `compatibility`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update the existing config tests**
|
||||||
|
|
||||||
|
`packages/styles/test/config.test.ts` asserts on the removed keys. Remove those assertions. Do not weaken any assertion that is still meaningful — if a test only existed to cover compatibility, delete the whole test.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/styles packages/cli`
|
||||||
|
Expected: PASS, including the new rejection tests.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add -A packages/styles packages/cli examples/basic-app
|
||||||
|
git commit -m "refactor: delete the compatibility config surface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Drop migrations below 0.8.0
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/cli/src/update.ts` (all `Migration` entries with `version` below `"0.8.0"`)
|
||||||
|
- Test: `packages/cli/test/update-migration-floor.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: nothing.
|
||||||
|
- Produces: the migration list starts at `0.8.0`.
|
||||||
|
|
||||||
|
`update.ts` holds 111 migrations reaching back to `0.2.8`. The framework is pre-public and the only projects run `0.8.x`, so everything below the floor is unreachable.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/update-migration-floor.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
test("no migration targets a version below 0.8.0", () => {
|
||||||
|
const source = readFileSync(join(import.meta.dir, "../src/update.ts"), "utf8");
|
||||||
|
const versions = [...source.matchAll(/version:\s*"([0-9.]+)"/g)].map((match) => match[1]!);
|
||||||
|
|
||||||
|
expect(versions.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const belowFloor = versions.filter((version) => {
|
||||||
|
const [major, minor] = version.split(".").map(Number);
|
||||||
|
return major! === 0 && minor! < 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(belowFloor).toEqual([]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/update-migration-floor.test.ts`
|
||||||
|
Expected: FAIL, listing the `0.2.x`–`0.7.x` versions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete the migrations below the floor**
|
||||||
|
|
||||||
|
Remove every `Migration` object whose `version` is below `"0.8.0"`, along with any helper function that becomes unused as a result. Keep every `0.8.x` entry.
|
||||||
|
|
||||||
|
After deleting, search for now-unreferenced helpers in the file and remove them too — an unused private helper is dead code, and the linter will flag it.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli`
|
||||||
|
Expected: PASS. Existing update tests that exercised removed migrations should be deleted with them; do not keep a test that asserts nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify `update` still runs end to end**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd examples/basic-app wrnexus update --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: completes without error and reports no pending migrations for an app already at the current version. Paste the output into the commit body if it is short.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/cli
|
||||||
|
git commit -m "chore: drop update migrations below 0.8.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Remove the deprecated compiler re-export shims
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/compiler/src/codegen.ts` (~line 20, the `./parser.ts` import)
|
||||||
|
- Modify: `packages/compiler/src/native-codegen.ts` (~line 1, the `./parser.ts` import)
|
||||||
|
- Delete: `packages/compiler/src/parser.ts`, `packages/compiler/src/tokenizer.ts`, `packages/compiler/src/types.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: nothing.
|
||||||
|
- Produces: nothing new; imports move to `@wrnexus/syntax`.
|
||||||
|
|
||||||
|
**Order matters.** These three files are two-line re-exports marked deprecated, but `codegen.ts` and `native-codegen.ts` still import from them. Deleting the files first breaks the build.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Repoint the imports**
|
||||||
|
|
||||||
|
In `packages/compiler/src/codegen.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
to import the same names from `@wrnexus/syntax`. If the file already imports from `@wrnexus/syntax`, merge them into that one import rather than adding a second.
|
||||||
|
|
||||||
|
In `packages/compiler/src/native-codegen.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Attr, PageAst, ViewNode } from "./parser.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
the same way.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify nothing else imports the shims**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "from \"./parser.ts\"\|from \"./tokenizer.ts\"\|from \"./types.ts\"" packages/compiler/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output. If anything remains, repoint it before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete the shims**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm packages/compiler/src/parser.ts packages/compiler/src/tokenizer.ts packages/compiler/src/types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/compiler && bun run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Rebuild the editor bundles and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
bun run --cwd editors/vscode build
|
||||||
|
git add -A packages/compiler editors/vscode/src
|
||||||
|
git commit -m "refactor: drop the deprecated compiler re-export shims"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Remove the deprecated `@wrnexus/auth` options
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/auth/src/http/index.ts` (~lines 56-59)
|
||||||
|
- Modify: `packages/auth/src/plugin.ts` (~lines 55-58)
|
||||||
|
- Modify: `packages/auth/src/types.ts` (~line 423)
|
||||||
|
- Modify: `packages/auth/src/engine.ts` (~lines 163-165 and 179-181)
|
||||||
|
- Modify: `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, `packages/auth/test/engine.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: nothing.
|
||||||
|
- Produces: nothing new. Options are removed, not renamed.
|
||||||
|
|
||||||
|
**These are our own superseded options, not an out-of-date dependency.** The current form is already what `examples/auth-showcase/app/lib/auth.ts` uses — it passes `onSignedIn` / `onSignedOut` to `createAuthEngine`, which is correct and must not change. The deprecated members are the same names on _different_ option objects.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the blast radius before deleting**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "onSuccessfullSignUp" packages/ examples/ services/ | grep -v dist/
|
||||||
|
grep -rn "onSignedIn\|onSignedOut" packages/ examples/ --include=*.ts | grep -v "packages/auth/src" | grep -v dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `onSuccessfullSignUp` has zero references. The `onSignedIn` / `onSignedOut` hits are `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, and `examples/auth-showcase/app/lib/auth.ts`. **The example is the correct `createAuthEngine` form and must be left alone.** Record what you found; if the results differ from this, stop and report before deleting anything.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the option declarations**
|
||||||
|
|
||||||
|
Delete `onSignedIn` and `onSignedOut` (and their `@deprecated` comments) from the options interface in `packages/auth/src/http/index.ts` and from `packages/auth/src/plugin.ts`. Delete `onSuccessfullSignUp` from `packages/auth/src/types.ts`. Delete the `rpId` and `origin` members from both verification signatures in `packages/auth/src/engine.ts`.
|
||||||
|
|
||||||
|
Then remove the code that reads them. The `rpId` / `origin` values are already ignored — verification uses the values bound to the issued challenge — so removing them changes no behaviour.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the tests that exercised the deprecated paths**
|
||||||
|
|
||||||
|
`packages/auth/test/http.test.ts` and `plugin.test.ts` pass the deprecated options. Rewrite each to use the `createAuthEngine` form where the test is still meaningful, and delete the test where its only purpose was to cover the deprecated alias.
|
||||||
|
|
||||||
|
`packages/auth/test/engine.test.ts` passes `rpId` / `origin` to verification. Remove those arguments; the assertions on the verification result should be unchanged, which is the evidence that the options were inert.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/auth && bun run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm no `@deprecated` markers remain in auth**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "@deprecated" packages/auth/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add -A packages/auth
|
||||||
|
git commit -m "refactor: remove the deprecated auth options"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Full gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: whatever the gate reports as stale (generated types, public API baseline, editor bundles)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Tasks 1-5.
|
||||||
|
- Produces: a green `check:production`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rebuild the editor bundles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd editors/vscode build
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiler and language server are embedded there and both changed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full gate**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
bun test
|
||||||
|
bun run typecheck
|
||||||
|
bun run check:production
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Regenerate anything the gate reports as stale**
|
||||||
|
|
||||||
|
`check:public-api` fails when exports change — and this plan removed several from `@wrnexus/styles`. Run `bun run generate:public-api`, then **read the diff and confirm it is removals only**. An unexpected addition means something was exported by accident.
|
||||||
|
|
||||||
|
`check:generated-types` may need `bun run --cwd examples/basic-app wrnexus generate types`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the gate until green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run check:production
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: regenerate baselines after the legacy cleanup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
|
||||||
|
- **The seven config keys are dead.** If you find code that actually reads one, stop and report — the spec's central claim would be wrong and the plan needs revisiting.
|
||||||
|
- **Task 1 is the only behaviour-sensitive change.** Its first two tests describe current behaviour and must pass both before and after. If they fail after, the substitution was not equivalent; report rather than editing the test.
|
||||||
|
- **The auth example is already correct.** `examples/auth-showcase` uses `createAuthEngine({ onSignedIn })`, which is the current API, not the deprecated one.
|
||||||
|
- **If a test would still pass with the code it guards deleted, it is not a test.** Delete the implementation, watch it fail, restore it.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
|||||||
|
# `wrnexus update` Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** One `wrnexus update` carries an existing project from today's syntax to the syntax left by the cleanup and `apis { }` plans — or refuses precisely, naming the file and the reason.
|
||||||
|
|
||||||
|
**Architecture:** These are new `Migration` entries in the existing `update.ts` framework, which already has dry-run support and a report that separates automatic changes from ones needing review. `.wrn` rewriting parses with `@wrnexus/syntax` and re-emits through `formatWrn`, both already imported there.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun, TypeScript, `bun:test`, `@wrnexus/syntax`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-19-update-migration-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **A file is transformed correctly, or it is left untouched and reported.** There is no third outcome — never a partial rewrite.
|
||||||
|
- Every migration is **idempotent**: running it twice changes nothing the second time.
|
||||||
|
- **Dry-run reports exactly what a real run would change**, and writes nothing.
|
||||||
|
- A run with anything in `needsReview` or `parseFailures` **exits non-zero**, so a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||||
|
- Migrations attach to the release that ships the breaking change, above the `0.8.0` floor.
|
||||||
|
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||||
|
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Remove the dead config keys
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/cli/src/update.ts` (add a `Migration`)
|
||||||
|
- Test: `packages/cli/test/migrate-config-keys.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: a migration with `id: "remove-dead-config-keys"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/migrate-config-keys.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { runMigrations } from "../src/update.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const CONFIG = `export default {
|
||||||
|
compatibilityDate: "2026-08-02",
|
||||||
|
frameworkBehaviour: 1,
|
||||||
|
functions: { legacyDefaultRuntime: "current" },
|
||||||
|
compatibility: { legacyEmit: false, stringLayouts: false },
|
||||||
|
observability: { sampleRate: 1 },
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
function project(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "wrnexus.config.ts"), CONFIG);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the removed keys are deleted and the rest is kept", async () => {
|
||||||
|
const root = project();
|
||||||
|
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||||
|
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(config).not.toContain("compatibilityDate");
|
||||||
|
expect(config).not.toContain("frameworkBehaviour");
|
||||||
|
expect(config).not.toContain("legacyDefaultRuntime");
|
||||||
|
expect(config).not.toContain("legacyEmit");
|
||||||
|
expect(config).toContain("observability");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running it twice changes nothing the second time", async () => {
|
||||||
|
const root = project();
|
||||||
|
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||||
|
const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||||
|
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||||
|
|
||||||
|
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dry run writes nothing", async () => {
|
||||||
|
const root = project();
|
||||||
|
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: true });
|
||||||
|
|
||||||
|
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use whatever entry point `update.ts` exports for running migrations; if the name differs from `runMigrations`, adapt the calls and keep the assertions identical.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/migrate-config-keys.test.ts`
|
||||||
|
Expected: FAIL — the keys survive.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the migration**
|
||||||
|
|
||||||
|
Append to the migration list in `packages/cli/src/update.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
version: "0.9.0",
|
||||||
|
id: "remove-dead-config-keys",
|
||||||
|
description: "Delete compatibilityDate, frameworkBehaviour, functions, and compatibility",
|
||||||
|
apply(ctx) {
|
||||||
|
const file = join(ctx.appRoot, "wrnexus.config.ts");
|
||||||
|
if (!existsSync(file)) return;
|
||||||
|
|
||||||
|
const before = readFileSync(file, "utf8");
|
||||||
|
// Each key is a whole property line or block; removing the line leaves
|
||||||
|
// valid TypeScript because these are always object members.
|
||||||
|
const after = before
|
||||||
|
.replace(/^\s*compatibilityDate:.*\n/m, "")
|
||||||
|
.replace(/^\s*frameworkBehaviour:.*\n/m, "")
|
||||||
|
.replace(/^\s*functions:\s*\{[^}]*\},?\s*\n/m, "")
|
||||||
|
.replace(/^\s*compatibility:\s*\{[^}]*\},?\s*\n/m, "");
|
||||||
|
|
||||||
|
if (after === before) return;
|
||||||
|
|
||||||
|
ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`);
|
||||||
|
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/cli
|
||||||
|
git commit -m "feat(cli): migrate away the dead config keys"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Move `ssr { api … }` / `client { api … }` into `apis { }`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/cli/src/migrations/apis-block.ts`
|
||||||
|
- Modify: `packages/cli/src/update.ts` (register the migration)
|
||||||
|
- Test: `packages/cli/test/migrate-apis-block.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `migrateApisBlock(source: string): { source: string; changed: boolean } | { skip: string }` — a pure function over `.wrn` text, so it is testable without a filesystem. `skip` carries the human-readable reason.
|
||||||
|
|
||||||
|
Sectioned bodies carry across unchanged, because the payload is already bound to `data`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/migrate-apis-block.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { migrateApisBlock } from "../src/migrations/apis-block.ts";
|
||||||
|
|
||||||
|
const SOURCE = `page Search {
|
||||||
|
client {
|
||||||
|
api searchUsers POST /api/users {
|
||||||
|
request { body { name?: string } }
|
||||||
|
response { return data.users }
|
||||||
|
error { return [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a client api entry moves into an apis block", () => {
|
||||||
|
const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean };
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.source).toContain("apis {");
|
||||||
|
expect(result.source).toContain("searchUsers POST /api/users");
|
||||||
|
expect(result.source).not.toContain("client {\n api");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the sections survive unchanged", () => {
|
||||||
|
const result = migrateApisBlock(SOURCE) as { source: string };
|
||||||
|
|
||||||
|
expect(result.source).toContain("return data.users");
|
||||||
|
expect(result.source).toContain("return []");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running it on migrated source changes nothing", () => {
|
||||||
|
const once = (migrateApisBlock(SOURCE) as { source: string }).source;
|
||||||
|
const twice = migrateApisBlock(once) as { source: string; changed: boolean };
|
||||||
|
|
||||||
|
expect(twice.changed).toBe(false);
|
||||||
|
expect(twice.source).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name declared in both modes is skipped with a reason", () => {
|
||||||
|
const clash = `page P {
|
||||||
|
ssr { api dup GET /api/a { response { return data } } }
|
||||||
|
client { api dup GET /api/a { response { return data } } }
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const result = migrateApisBlock(clash) as { skip: string };
|
||||||
|
|
||||||
|
expect(result.skip).toContain("dup");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/migrate-apis-block.test.ts`
|
||||||
|
Expected: FAIL — the module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the transform**
|
||||||
|
|
||||||
|
Create `packages/cli/src/migrations/apis-block.ts`. Parse with `parse` from `@wrnexus/syntax` to find the entries and validate the file, collect every `api` entry from `ssr` / `client` blocks, detect duplicate names across modes and return `{ skip }` when found, then emit one `apis { }` block and delete the now-empty mode blocks. Re-emit through `formatWrn`.
|
||||||
|
|
||||||
|
Detect already-migrated input by checking whether the source has an `apis` block and no mode data blocks; return `{ source, changed: false }`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register it**
|
||||||
|
|
||||||
|
Add a `Migration` with `id: "move-api-blocks"` that walks `app/**/*.wrn`, calls `migrateApisBlock`, and routes the outcome: a change goes to `changedAutomatically`, a `skip` goes to `needsReview` with the file and reason, and a `parse` failure goes to `parseFailures` with the file left untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/cli
|
||||||
|
git commit -m "feat(cli): migrate api entries into the apis block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Move mode-scoped helpers into `functions { shared … }`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/cli/src/migrations/mode-functions.ts`
|
||||||
|
- Modify: `packages/cli/src/update.ts`
|
||||||
|
- Test: `packages/cli/test/migrate-mode-functions.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `migrateModeFunctions(source: string): { source: string; changed: boolean } | { skip: string }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/migrate-mode-functions.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
|
||||||
|
|
||||||
|
const SOURCE = `page Hello {
|
||||||
|
ssr {
|
||||||
|
functions {
|
||||||
|
function userNames(users) {
|
||||||
|
return users.map((user) => user.name).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a mode helper becomes a shared function", () => {
|
||||||
|
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.source).toContain("shared function userNames");
|
||||||
|
expect(result.source).not.toContain("ssr {");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running it again changes nothing", () => {
|
||||||
|
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
|
||||||
|
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
|
||||||
|
|
||||||
|
expect(twice.changed).toBe(false);
|
||||||
|
expect(twice.source).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name that already exists at page level is skipped with a reason", () => {
|
||||||
|
const clash = `page P {
|
||||||
|
functions { shared function userNames() { return "" } }
|
||||||
|
ssr { functions { function userNames(users) { return "" } } }
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const result = migrateModeFunctions(clash) as { skip: string };
|
||||||
|
|
||||||
|
expect(result.skip).toContain("userNames");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/migrate-mode-functions.test.ts`
|
||||||
|
Expected: FAIL — the module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement and register**
|
||||||
|
|
||||||
|
Create the module following Task 2's shape: relocate each mode-scoped function into the page-level `functions { }` with the `shared` modifier, skipping the file with a reason when a name already exists there. Register a `Migration` with `id: "move-mode-functions"` that routes outcomes to the same three report buckets.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/cli
|
||||||
|
git commit -m "feat(cli): migrate mode-scoped helpers to shared functions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Detect legacy bare-body blocks and report them — do not rewrite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/cli/src/migrations/legacy-api-body.ts`
|
||||||
|
- Modify: `packages/cli/src/update.ts`
|
||||||
|
- Test: `packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `detectLegacyApiBodies(source: string): { name: string; freeIdentifiers: string[] }[]`.
|
||||||
|
|
||||||
|
**This transform is deliberately manual, and the test pins that.** A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare identifiers. Converting `return userNames(users)` needs `data.users` — but **nothing in the source distinguishes `users` (payload) from `userNames` (page helper)**. The response shape belongs to the route, which may not be typed. A migration that guessed would emit code that compiles and is wrong.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/migrate-legacy-api-body.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
|
||||||
|
|
||||||
|
const SOURCE = `page Hello {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users/ssr {
|
||||||
|
return userNames(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a legacy bare body is detected with its free identifiers", () => {
|
||||||
|
const found = detectLegacyApiBodies(SOURCE);
|
||||||
|
|
||||||
|
expect(found).toHaveLength(1);
|
||||||
|
expect(found[0]!.name).toBe("ssrUsers");
|
||||||
|
expect(found[0]!.freeIdentifiers).toContain("users");
|
||||||
|
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sectioned block is not reported", () => {
|
||||||
|
const sectioned = `page P {
|
||||||
|
apis { x GET /api/x { response { return data.users } } }
|
||||||
|
view { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||||
|
Expected: FAIL — the module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement detection only**
|
||||||
|
|
||||||
|
Create the module. Find `api` entries whose `sections` is absent (the bare-body form), and collect the free identifiers in the body — identifiers that are not declared locally and are not JavaScript globals. Return them. **Write no transform.**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register a report-only migration**
|
||||||
|
|
||||||
|
Add a `Migration` with `id: "report-legacy-api-bodies"` that pushes one `needsReview` entry per block, naming the file, the block, and the identifiers, and leaves the file byte-identical. Have `wrnexus update` print a short line explaining why this one is manual: the payload fields cannot be told apart from page helpers without knowing the route's response shape.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the byte-identical test**
|
||||||
|
|
||||||
|
Add a test that runs the full migration over a fixture project containing a legacy bare body and asserts the file's contents are unchanged afterwards, and that the report names the block. **This is the most important test in the plan** — it pins that the migration does not attempt the rewrite.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the tests**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add packages/cli
|
||||||
|
git commit -m "feat(cli): report legacy api bodies for manual migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Exit code, output order, and the end-to-end run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/cli/src/update.ts` (the command's output and exit code)
|
||||||
|
- Test: `packages/cli/test/update-exit-code.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Tasks 1-4.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `packages/cli/test/update-exit-code.test.ts` asserting that a project with a legacy bare body produces a non-zero exit, and a fully-migratable project produces zero. Use the same temp-project pattern as Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test packages/cli/test/update-exit-code.test.ts`
|
||||||
|
Expected: FAIL — the command currently exits zero regardless.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the output and exit code**
|
||||||
|
|
||||||
|
Print, in order: what changed, what needs review and why, what failed to parse. Exit non-zero when `needsReview` or `parseFailures` is non-empty.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Migrate the example app with the command alone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd examples/basic-app wrnexus update
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the `.wrn` pages are migrated by the tool, not by hand. **If the framework's own example cannot be migrated by the tool, the tool is not finished** — report that rather than editing the example manually.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the migrated example**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd examples/basic-app build
|
||||||
|
bun test
|
||||||
|
bun run typecheck
|
||||||
|
bun run check:production
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(cli): fail the update when a project needs manual review"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
|
||||||
|
- **Never half-rewrite a file.** Parse first; on failure, record and move on. If any part of a file's transform cannot complete, skip the whole file and report it.
|
||||||
|
- **Idempotency is not optional.** Every transform detects already-migrated input.
|
||||||
|
- **Task 4 writes no transform.** If you find yourself building one, stop — the spec explains why a correct automatic answer does not exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-execution note (2026-08-20)
|
||||||
|
|
||||||
|
**Step 4 of Task 5 did not prove what it was written to prove.** By the time it ran,
|
||||||
|
`examples/basic-app` had already been moved to the current syntax by hand in commit
|
||||||
|
`890d6106`, so `wrnexus update` migrated a no-op target: 0 changed, 0 needing review,
|
||||||
|
0 parse failures. The framework's own example therefore does NOT demonstrate the tool
|
||||||
|
against real legacy syntax.
|
||||||
|
|
||||||
|
The guarantee instead rests on fixture-based tests that drive the real `updateApp` /
|
||||||
|
`runUpdate` entry points over projects containing genuine `ssr {}` / `client {}` source
|
||||||
|
-- including the mixed-content page that only converges because `move-mode-functions`
|
||||||
|
is registered before `move-api-blocks`. That is adequate coverage, but it is a weaker
|
||||||
|
kind of evidence than the plan intended, and it is recorded here rather than quietly
|
||||||
|
counted as a pass.
|
||||||
@@ -29,7 +29,7 @@ byte-for-byte unchanged, and a route with no islands must ship no React.
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
| Question | Decision |
|
| Question | Decision |
|
||||||
|---|---|
|
| ---------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| Purpose | npm ecosystem access |
|
| Purpose | npm ecosystem access |
|
||||||
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
||||||
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
||||||
@@ -100,7 +100,7 @@ separate island hydration channel is introduced.
|
|||||||
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
||||||
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
||||||
referentially identical value when nothing has changed; otherwise React throws
|
referentially identical value when nothing has changed; otherwise React throws
|
||||||
*"The result of getSnapshot should be cached to avoid an infinite loop"* and spins.
|
_"The result of getSnapshot should be cached to avoid an infinite loop"_ and spins.
|
||||||
|
|
||||||
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
||||||
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
||||||
@@ -183,7 +183,7 @@ point.
|
|||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
| Condition | Behavior |
|
| Condition | Behavior |
|
||||||
|---|---|
|
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
||||||
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
||||||
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
||||||
|
|||||||
@@ -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,250 @@
|
|||||||
|
# The `apis { }` block and location-transparent dispatch — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** One container block for API declarations, callable from anywhere as `api.<name>(input)`,
|
||||||
|
dispatched in-process on the server and over `fetch` in the browser.
|
||||||
|
|
||||||
|
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md`. That spec removes the
|
||||||
|
compatibility surface and the `"legacy"` function runtime; this one removes the legacy `api` forms
|
||||||
|
by replacing them.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Today a `.wrn` page has several unrelated ways to reach data: an `api` block inside `ssr {}`, an
|
||||||
|
`api` block inside `client {}`, a `server function` over RPC, a `load server` block, and hand-written
|
||||||
|
`fetch`. Each has different placement, different capabilities, and a different call shape. The
|
||||||
|
result is that "how do I fetch this?" has no single answer, and the answer that is right depends on
|
||||||
|
where the code happens to sit.
|
||||||
|
|
||||||
|
This collapses the API half of that into one declaration and one call, and draws a line a developer
|
||||||
|
can hold in their head:
|
||||||
|
|
||||||
|
- **`api.<name>()`** — call an API route that exists as a real HTTP endpoint.
|
||||||
|
- **`server.<name>()`** — call server-side logic that has no public surface.
|
||||||
|
|
||||||
|
The question becomes "is there a route?", not "where am I running?". `server.<name>()` is unchanged
|
||||||
|
by this spec.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Changing `server function`, actions, or `load` blocks. They keep working exactly as they do.
|
||||||
|
- External or third-party API targets. Still this app's `/api/*` routes only, preserving
|
||||||
|
`isSafeApiPath`.
|
||||||
|
- Any new configuration key. This spec adds none.
|
||||||
|
- Author-settable request headers, still excluded.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Container | Page-level `apis { }`, matching `functions { }` |
|
||||||
|
| Declaration | Mode-less — no `ssr` / `client` prefix |
|
||||||
|
| Call | `api.<name>(input)` from any context |
|
||||||
|
| Dispatch | Chosen at build time: in-process on the server, `fetch` in the browser |
|
||||||
|
| Server request context | `AsyncLocalStorage` |
|
||||||
|
| Browser emission | Only blocks the client actually calls |
|
||||||
|
| Render binding | `api="name"`, `api="name()"`, `api="name({ … })"` |
|
||||||
|
| Old forms | Removed and replaced |
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
apis {
|
||||||
|
searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response { return data.data.users }
|
||||||
|
error { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
listTeams GET /api/teams {
|
||||||
|
response { return data.data.teams }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET` and `HEAD` declare `parameters`, which become a query string; other methods declare `body`,
|
||||||
|
sent as JSON. The path stays a plain literal so `isSafeApiPath` is satisfied without relaxing it.
|
||||||
|
|
||||||
|
Names are unique per page — `codegen.ts` already rejects duplicates, and that stays.
|
||||||
|
|
||||||
|
### Why the container
|
||||||
|
|
||||||
|
`functions { }` puts the modifier first inside a container named for the concept:
|
||||||
|
`functions { client function x() }`. Today's api form inverts that — `client { api x … }` — and is
|
||||||
|
the only construct in the language shaped that way, which is why APIs are hard to find in a page.
|
||||||
|
`apis` (plural) is the container; `api` remains the call namespace and the binding attribute.
|
||||||
|
|
||||||
|
## Calling
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
functions {
|
||||||
|
client async function search(): Promise<void> {
|
||||||
|
users = await api.searchUsers({ name: nameFilter })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load server directory {
|
||||||
|
return await api.searchUsers({ name: ctx.url.searchParams.get("name") ?? "" })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same call works in client functions, `load` blocks, actions, and server functions. Nothing at
|
||||||
|
the call site says where it runs.
|
||||||
|
|
||||||
|
### Dispatch
|
||||||
|
|
||||||
|
Dispatch is decided at build time, not by a runtime check. The compiler emits an `api` object into
|
||||||
|
each execution context, with the same member names and different transports behind them:
|
||||||
|
|
||||||
|
- **Browser** — the existing `callApi` transport: query string or JSON body, `credentials:
|
||||||
|
"same-origin"`, `x-csrf-token` on non-GET, the established failure contract.
|
||||||
|
- **Server** — `callApiFromContext`, which dispatches to the route in-process. No network hop, no
|
||||||
|
serialisation round trip, and the caller's cookies, session, and locals are already forwarded.
|
||||||
|
|
||||||
|
Two objects that never meet, so nothing ships to the browser that only the server uses, and the
|
||||||
|
runtime never branches on `typeof window` for something known at compile time.
|
||||||
|
|
||||||
|
### `callApiFromContext` must learn to carry input
|
||||||
|
|
||||||
|
It currently builds `new Request(apiUrl, { method, headers })` — no body, no query string. It has to
|
||||||
|
assemble the request the same way the browser transport does, from the same rules, or the two sides
|
||||||
|
will disagree about what an identical call sends. **The assembly rules must be shared, not
|
||||||
|
reimplemented**: a second copy will drift, and the drift will be silent because each side is tested
|
||||||
|
separately.
|
||||||
|
|
||||||
|
### The request context
|
||||||
|
|
||||||
|
A server-side call needs `ctx` to resolve the URL and forward cookies and session. `ctx` is not
|
||||||
|
uniformly available: `load` blocks have it, schema actions have it as a second parameter, plain
|
||||||
|
actions and server functions have neither.
|
||||||
|
|
||||||
|
The server stores the request context in an `AsyncLocalStorage` at request entry, and the server
|
||||||
|
`api` object reads it. This is new machinery — the framework uses none today — and it must be
|
||||||
|
established in both the dev server and the production server, or a call that works in development
|
||||||
|
fails in production.
|
||||||
|
|
||||||
|
When no context is present, the call throws with a message naming the block and explaining that an
|
||||||
|
API call needs a request context — never a silent `undefined`.
|
||||||
|
|
||||||
|
## Emission
|
||||||
|
|
||||||
|
A block's `response` and `error` bodies are page code. They ship to the browser **only when a
|
||||||
|
client-side call to that block exists**. The compiler already knows which `api.<name>()` calls
|
||||||
|
appear in client functions.
|
||||||
|
|
||||||
|
This keeps server-only transforms off the wire and the client bundle proportional to what it uses.
|
||||||
|
The consequence to know: adding the first client call to a block starts shipping that block's
|
||||||
|
bodies. Anything secret belongs in the endpoint, not in a `response` body.
|
||||||
|
|
||||||
|
## Render binding
|
||||||
|
|
||||||
|
A block can be bound into markup, which calls it during render and substitutes the result:
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
<p api="listTeams">loading…</p>
|
||||||
|
<ul api="searchUsers({ name: nameFilter })">
|
||||||
|
{#each searchUsers as person}<li>{person.name}</li>{/each}
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
Three accepted forms: `api="name"`, `api="name()"` — equivalent — and `api="name({ … })"`, which
|
||||||
|
passes arguments. The argument expression is evaluated in the same scope as other view expressions
|
||||||
|
at render time. This mirrors `@click="search()"`, so it introduces no new escaping or naming rules.
|
||||||
|
|
||||||
|
### The edge this creates, stated plainly
|
||||||
|
|
||||||
|
A block that is both render-bound and called from code **runs twice** — once for the binding, once
|
||||||
|
for the call. They are two different lifecycles wearing one name, and no deduplication is attempted:
|
||||||
|
a render-time fetch and a user-triggered fetch are usually meant to be different requests, and
|
||||||
|
silently collapsing them would be worse than the duplication. Authors binding a block _and_ calling
|
||||||
|
it should expect two requests.
|
||||||
|
|
||||||
|
## Replacing the old forms
|
||||||
|
|
||||||
|
`ssr { … }` and `client { … }` data blocks are removed. Each could contain only two things, and both
|
||||||
|
have a home:
|
||||||
|
|
||||||
|
| Old | New |
|
||||||
|
| ------------------------------------------------ | ------------------------------------------------------ |
|
||||||
|
| `ssr { api x … }` / `client { api x … }` | `apis { x … }` |
|
||||||
|
| `ssr { functions { function helper() } }` | `functions { shared function helper() }` |
|
||||||
|
| legacy bare-body `api x GET /p { return users }` | `apis { x GET /p { response { return data.users } } }` |
|
||||||
|
|
||||||
|
The legacy bare body injected the payload with `with ($data ?? {})`, which is **untypeable** —
|
||||||
|
TypeScript cannot see through `with`, and that is the entire reason sectioned blocks bind a named
|
||||||
|
`data`. Removing the legacy form removes that fork: one payload binding, typed.
|
||||||
|
|
||||||
|
`client state { }` is a different construct that shares the keyword and is **not** affected.
|
||||||
|
|
||||||
|
`examples/basic-app/app/pages/hello.wrn` uses both old forms and is the migration's worked example.
|
||||||
|
|
||||||
|
## Type safety
|
||||||
|
|
||||||
|
Assertions are generated into `app/types/wrnexus.generated.api-checks.ts` and checked by the
|
||||||
|
project's own `tsc`, as established. Two changes follow from mode-less declarations:
|
||||||
|
|
||||||
|
- The current generator skips blocks whose `mode !== "client"`. That skip exists because an `ssr`
|
||||||
|
block could never declare a `request`. Mode-less blocks invalidate the reasoning, so **every block
|
||||||
|
with declared fields gets an assertion**.
|
||||||
|
- The zero-field skip stays: a block with no declared fields has nothing to check, and asserting
|
||||||
|
`Record<string, never>` against a contract fails spuriously.
|
||||||
|
|
||||||
|
## Known edges
|
||||||
|
|
||||||
|
- **`state api` stops working.** The name is currently excluded from destructuring only when a page
|
||||||
|
has client api blocks, so pages without them keep using it. Once `api` is universal that
|
||||||
|
protection goes, and a page with `state api` breaks. It is a build-time failure, not silent.
|
||||||
|
- **Every block is browser-reachable in principle.** The routes were already publicly reachable by
|
||||||
|
`fetch`, so this exposes no new surface — but a block is no longer implicitly server-only by
|
||||||
|
virtue of its placement.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Parser**
|
||||||
|
|
||||||
|
- `apis { }` parses multiple mode-less entries; duplicate names are rejected.
|
||||||
|
- `ssr { api … }` and `client { api … }` are rejected with a message naming the replacement.
|
||||||
|
- All three binding forms parse, including an argument expression containing a nested object.
|
||||||
|
|
||||||
|
**Dispatch**
|
||||||
|
|
||||||
|
- A client-side call issues one `fetch` with the expected URL, method, body, and CSRF header.
|
||||||
|
- A server-side call dispatches in-process and issues **no** network request — asserted by observing
|
||||||
|
that no fetch occurs, not merely that the result is right.
|
||||||
|
- A server-side call with no request context throws a message naming the block.
|
||||||
|
- The same declared input produces the same request on both sides — the shared-assembly guard.
|
||||||
|
|
||||||
|
**Emission**
|
||||||
|
|
||||||
|
- A block called only from server code does not appear in the browser module.
|
||||||
|
- A block called from a client function does.
|
||||||
|
|
||||||
|
**Render binding**
|
||||||
|
|
||||||
|
- Each of the three forms renders the resolved value.
|
||||||
|
- A bound block that is also called issues two requests, pinning the documented edge.
|
||||||
|
|
||||||
|
**Type safety**
|
||||||
|
|
||||||
|
- A block declaring a field its endpoint rejects fails `bun run typecheck`, asserted by running
|
||||||
|
`tsc` and reading its diagnostics — not by matching generated text.
|
||||||
|
|
||||||
|
**End to end**
|
||||||
|
|
||||||
|
- `examples/basic-app` migrated to `apis { }`, driven in a browser: a client call updates state, a
|
||||||
|
render-bound block appears in the served HTML, and a deliberately failing call takes the `error`
|
||||||
|
path.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- External API targets, and the allowlist and credential handling they need.
|
||||||
|
- Author-settable headers.
|
||||||
|
- Deduplicating a render-bound block against a code call.
|
||||||
|
- Response caching and request de-duplication.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Editor support for the new syntax — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** Language server and VS Code extension updated for `apis { }`, the `api.<name>()` call, and
|
||||||
|
the removal of the mode data blocks.
|
||||||
|
|
||||||
|
**Depends on:** `2026-08-19-apis-block-design.md`. The syntax must exist before the editor can
|
||||||
|
describe it.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A syntax change that the editor does not know about is worse than no change: valid code is
|
||||||
|
red-underlined, removed constructs still autocomplete, and the new block gets no highlighting. This
|
||||||
|
spec keeps the tooling level with the language.
|
||||||
|
|
||||||
|
It also settles a question left open by earlier work: whether type errors from the generated
|
||||||
|
assertions actually appear inside the `.wrn` file, or only in the generated file. That was never
|
||||||
|
verified — it was inferred from reading source — and inference is not good enough for the thing
|
||||||
|
developers rely on to tell them their code is wrong.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- New editor features unrelated to this syntax change.
|
||||||
|
- Editors other than VS Code beyond what standard LSP provides.
|
||||||
|
- HTML formatting — `formatWrn` still owns markup.
|
||||||
|
|
||||||
|
## What exists
|
||||||
|
|
||||||
|
- **Grammar:** `editors/vscode/syntaxes/wrn.tmLanguage.json` names block keywords directly —
|
||||||
|
`api` appears 4 times, `client` 6, `server` 5, `ssr` once.
|
||||||
|
- **Keyword list:** `WRN_KEYWORDS` in `packages/language-server/src/index.ts` drives completion and
|
||||||
|
includes `api` but not `apis`.
|
||||||
|
- **Extension completions:** `editors/vscode/src/completion.js` carries block snippets.
|
||||||
|
- **Server features:** completion, hover, folding, linked editing, tag completion, and TypeScript
|
||||||
|
diagnostics over a virtual document.
|
||||||
|
|
||||||
|
## Changes by surface
|
||||||
|
|
||||||
|
### Grammar
|
||||||
|
|
||||||
|
Add `apis` as a block keyword. Remove the `ssr` / `client` **data block** patterns, keeping `client`
|
||||||
|
and `server` where they mean other things — `client state { }`, the `runtime` values, and the
|
||||||
|
function modifiers in `functions { }`. This is the change most likely to over-reach: `client` is one
|
||||||
|
word with several jobs in this language, and blanket removal would un-highlight constructs that
|
||||||
|
still exist.
|
||||||
|
|
||||||
|
Highlight an `apis` entry's shape — name, method, path — so a declaration reads as a declaration
|
||||||
|
rather than as loose identifiers.
|
||||||
|
|
||||||
|
### Keyword and block completion
|
||||||
|
|
||||||
|
- `apis` joins `WRN_KEYWORDS`.
|
||||||
|
- A snippet for the container and a snippet for an entry, including the `request` / `response` /
|
||||||
|
`error` sections, so the shape is discoverable without the docs.
|
||||||
|
- `ssr` and `client` data-block snippets are removed from `completion.js`. Offering a construct the
|
||||||
|
compiler rejects is worse than offering nothing.
|
||||||
|
|
||||||
|
### Call completion — the feature worth building
|
||||||
|
|
||||||
|
Inside a function body, `api.` should complete to the names declared in that page's `apis { }`
|
||||||
|
block, with the method and path as detail. The server already indexes the document to build
|
||||||
|
completions, and the block names are in the AST.
|
||||||
|
|
||||||
|
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor
|
||||||
|
should know it. Without it, `api.` is an empty namespace and every call is typed from memory.
|
||||||
|
|
||||||
|
Hovering a name inside `api.<name>()` shows its method, path, and declared request fields.
|
||||||
|
|
||||||
|
### The `api=` attribute in markup
|
||||||
|
|
||||||
|
`api="searchUsers({ name: nameFilter })"` is an attribute whose value is a call expression. The HTML
|
||||||
|
service must not flag it as an unknown attribute, and the expression must not be treated as plain
|
||||||
|
text. Completion inside the quotes offers the page's block names, matching the `api.` behaviour.
|
||||||
|
|
||||||
|
### Diagnostics for removed constructs
|
||||||
|
|
||||||
|
An `ssr { api … }` or `client { api … }` block gets a diagnostic naming `apis { }` as the
|
||||||
|
replacement, positioned on the block keyword. The compiler already rejects these; the editor should
|
||||||
|
say so while typing rather than at build time, and it should say what to do instead.
|
||||||
|
|
||||||
|
## Inline type errors — verifying, not assuming
|
||||||
|
|
||||||
|
The `apis` spec generates assertions into `app/types/wrnexus.generated.api-checks.ts`, and `tsc`
|
||||||
|
fails when a block declares a field its endpoint rejects. Whether that failure surfaces **inside the
|
||||||
|
`.wrn` file** has never been confirmed.
|
||||||
|
|
||||||
|
This spec resolves it in two steps, in order:
|
||||||
|
|
||||||
|
1. **Observe the current behaviour.** With a deliberately wrong field in place, open the page in VS
|
||||||
|
Code and record where the error appears: on the block, only in the generated file, or nowhere.
|
||||||
|
2. **Act on what is observed.** If the error already surfaces usefully, document it and stop. If it
|
||||||
|
appears only in the generated file, map the diagnostic back to the block that produced it — the
|
||||||
|
generator knows which page and block each assertion came from, so the mapping is available if it
|
||||||
|
is recorded rather than discarded.
|
||||||
|
|
||||||
|
If step 2 proves larger than this spec can hold, it becomes its own work, and the spec says so
|
||||||
|
plainly rather than leaving an unfinished feature implied. **Nothing here should claim inline
|
||||||
|
diagnostics work until someone has seen them work.**
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Grammar** — a fixture page using `apis { }`, `client state { }`, `functions { shared function }`,
|
||||||
|
and `runtime = "client"` tokenizes correctly; `client` keeps its highlighting everywhere it is still
|
||||||
|
valid. This is the guard against over-reaching removal.
|
||||||
|
|
||||||
|
**Completion**
|
||||||
|
|
||||||
|
- `apis` is offered at page level; `ssr` / `client` data blocks are not.
|
||||||
|
- `api.` inside a function body offers the page's declared names with method and path.
|
||||||
|
- Inside `api="…"` in markup, the same names are offered.
|
||||||
|
- Outside those contexts, completion is unchanged — the guard that non-API editing is undisturbed.
|
||||||
|
|
||||||
|
**Hover** — a name inside `api.<name>()` reports its method, path, and request fields.
|
||||||
|
|
||||||
|
**Diagnostics** — an `ssr { api … }` block produces a diagnostic naming `apis { }`, positioned on
|
||||||
|
the block keyword.
|
||||||
|
|
||||||
|
**Bundles** — `check:editor-compiler`, `check:editor-language-server`, and
|
||||||
|
`check:editor-extension` pass. These embed the compiler and language server, so they must be rebuilt
|
||||||
|
after the syntax change; a stale bundle fails the gate.
|
||||||
|
|
||||||
|
**Manual, and recorded in the implementation notes** — open the migrated `examples/basic-app` in VS
|
||||||
|
Code: `apis { }` highlights, `api.` completes, a removed construct is flagged, and the inline
|
||||||
|
type-error question above is answered by observation.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Mapping generated assertion diagnostics back into `.wrn`, if step 2 above proves too large.
|
||||||
|
- Moving the remaining component intelligence out of `completion.js` and into the server.
|
||||||
|
- Editors other than VS Code.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Legacy, deprecated, and unused-config cleanup — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** Remove the compatibility-flag surface, the `"legacy"` function runtime, dead migrations,
|
||||||
|
and deprecated APIs — before the framework's first public release.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
WRNexus carries compatibility machinery for a public it does not yet have. Every branch of it is
|
||||||
|
either switched off in the only apps that exist, or wired to nothing at all. Removing it now costs
|
||||||
|
almost nothing; removing it after release costs a major version and other people's time.
|
||||||
|
|
||||||
|
## Why this is safe now
|
||||||
|
|
||||||
|
Two pieces of evidence, both verified rather than assumed:
|
||||||
|
|
||||||
|
**The only consumers already run without it**, which matters for the config files themselves even
|
||||||
|
though the keys are inert. `D:\Company\wrnexus\apps\admin` and
|
||||||
|
`D:\Company\wrnexus\apps\web` — both test projects, neither deployed — set every compatibility flag
|
||||||
|
to `false` and `legacyDefaultRuntime: "current"`. They are already on the modern path; removing the
|
||||||
|
flags means deleting the lines that say "off".
|
||||||
|
|
||||||
|
**None of the seven keys change any behaviour.** Verified by tracing every reference, not by
|
||||||
|
reading the types. `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`,
|
||||||
|
and `legacyDefaultRuntime` appear in exactly three places each: the type declaration in
|
||||||
|
`config.ts`, the scaffolder in `create.ts`, and the insertion in `update.ts`. **No compiler,
|
||||||
|
codegen, or runtime code reads any of them.** They are written into every generated config and then
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
The remaining two are the same story with more machinery. `resolveCompatibility` (`packages/styles/src/compatibility.ts`)
|
||||||
|
produces a report — an effective date, a behaviour number, and advisory strings. Tracing every
|
||||||
|
consumer: the `wrnexus compatibility` command prints it, and `config.ts` raises one validation error
|
||||||
|
when the configured date is _newer_ than the CLI supports. **No compiler branch and no runtime
|
||||||
|
behaviour reads `effectiveDate` or `effectiveBehaviour`.** The mechanism is scaffolding that was
|
||||||
|
never connected.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **The legacy `api` block forms.** Bare-body blocks using `with ($data)`, and `api` entries inside
|
||||||
|
`ssr {}` / `client {}`, are _replaced_ rather than deleted — that is the next spec's job. Removing
|
||||||
|
them here would leave a gap with no working mechanism.
|
||||||
|
- Adding any configuration key. This spec only removes them.
|
||||||
|
- Changing any behaviour that is currently switched on.
|
||||||
|
|
||||||
|
## What gets removed
|
||||||
|
|
||||||
|
| Item | Where | Why it goes |
|
||||||
|
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
||||||
|
| `compatibility: { legacyEmit, legacyEventProps, legacyComponentDiscovery, stringLayouts }` | `packages/styles/src/config.ts` (`CompatibilityConfig`) | Never read by any code |
|
||||||
|
| `functions: { legacyDefaultRuntime }` | `packages/styles/src/config.ts` (`FunctionsConfig`) | Never read by any code |
|
||||||
|
| `compatibilityDate` | `packages/styles/src/compatibility.ts` (`CompatibilityPolicy`) | Gates nothing |
|
||||||
|
| `frameworkBehaviour` | same | Gates nothing |
|
||||||
|
| `resolveCompatibility`, `CompatibilityReport`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR` | `packages/styles/src/compatibility.ts` | Whole module serves only the two dead keys |
|
||||||
|
| `wrnexus compatibility <check\|explain\|upgrade>` | `packages/cli/src/compatibility-command.ts`, dispatch at `packages/cli/src/index.ts:295`, help text at `:75` | Reports on removed keys |
|
||||||
|
| `"legacy"` variant of `FunctionRuntime` | `packages/syntax/src/v060.ts`, branches in `packages/compiler/src/client-codegen.ts` and `server-codegen.ts` | An unmarked function becomes `shared` (see below) |
|
||||||
|
| Migrations below `0.8.0` | `packages/cli/src/update.ts` | 111 migrations reach back to `0.2.8`; no project exists below `0.8.x` |
|
||||||
|
| Deprecated re-export shims | `packages/compiler/src/{parser,tokenizer,types}.ts` | Two lines each, re-exporting `@wrnexus/syntax` |
|
||||||
|
| Deprecated `@wrnexus/auth` options | `engine.ts` (4 sites), `http/index.ts` (2), `plugin.ts` (2), `types.ts` (1) | See "Deprecated auth options" |
|
||||||
|
|
||||||
|
## The `"legacy"` function runtime
|
||||||
|
|
||||||
|
`FunctionRuntime` is `"legacy" | "client" | "server" | "shared"`. `"legacy"` is what an _unmarked_
|
||||||
|
`function foo()` gets, and `legacyDefaultRuntime` decides how it behaves. Both codegens then test
|
||||||
|
membership: `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]`
|
||||||
|
for the server — which is to say **an unmarked function is currently emitted into both bundles,
|
||||||
|
exactly like `shared`.**
|
||||||
|
|
||||||
|
`legacyDefaultRuntime` looks like it should modulate this, but it is never read (above), so the
|
||||||
|
mapping is unconditional: unmarked is always `"legacy"`, and `"legacy"` is always emitted to both
|
||||||
|
bundles.
|
||||||
|
|
||||||
|
So the removal is mechanical: delete the `"legacy"` variant, and parse an unmarked function as
|
||||||
|
`"shared"`. The emitted output for every existing unmarked function is unchanged, in every
|
||||||
|
configuration. `FunctionRuntime`
|
||||||
|
becomes `"client" | "server" | "shared"`, and the membership tests lose one element each.
|
||||||
|
|
||||||
|
This is the one item where behaviour could drift if done carelessly, so its test is explicit: an
|
||||||
|
unmarked function must still appear in both the browser and server modules.
|
||||||
|
|
||||||
|
## Deprecated re-export shims
|
||||||
|
|
||||||
|
`packages/compiler/src/parser.ts`, `tokenizer.ts`, and `types.ts` are two-line files re-exporting
|
||||||
|
`@wrnexus/syntax`. They are marked deprecated, but **`codegen.ts` and `native-codegen.ts` still
|
||||||
|
import from them**, so deleting the files is not enough — those imports must be repointed at
|
||||||
|
`@wrnexus/syntax` first. Removing the files without that step breaks the build.
|
||||||
|
|
||||||
|
## Deprecated auth options
|
||||||
|
|
||||||
|
`@wrnexus/auth` carries nine `@deprecated` markers. These are **our own superseded options**, not an
|
||||||
|
out-of-date dependency — there is no newer version to move to, only newer options we already added.
|
||||||
|
Removing them means deleting the old aliases and moving the few call sites that still use them.
|
||||||
|
|
||||||
|
| Group | Where | Replacement | Still used? |
|
||||||
|
| --------------------------------------------------------------------- | ---------------------------- | ------------------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| `onSignedIn` / `onSignedOut` on HTTP route options and plugin options | `http/index.ts`, `plugin.ts` | the same names on `createAuthEngine({ … })` | only `packages/auth/test/http.test.ts` and `plugin.test.ts` |
|
||||||
|
| `onSuccessfullSignUp` (misspelled alias) | `types.ts` | `onSuccessfulSignUp` | nowhere — zero references |
|
||||||
|
| `rpId` / `origin` on passkey verification | `engine.ts` (4 sites) | values bound to the issued challenge | `packages/auth/test/engine.test.ts` |
|
||||||
|
|
||||||
|
Two things this table settles:
|
||||||
|
|
||||||
|
- **The recommended form is already in use.** `examples/auth-showcase/app/lib/auth.ts` passes
|
||||||
|
`onSignedIn` / `onSignedOut` to `createAuthEngine`, which is the _current_ API. The deprecated
|
||||||
|
members are the same names on different option objects, so the example needs no change.
|
||||||
|
- **Neither test app uses any of them.** Nothing in `D:\Company\wrnexuspps` references these
|
||||||
|
options.
|
||||||
|
|
||||||
|
So the blast radius is three test files inside `packages/auth`, which are exercising the deprecated
|
||||||
|
paths and are updated or removed alongside them. The `rpId` / `origin` options are already ignored at
|
||||||
|
runtime — verification uses the values bound to the issued challenge — so removing them changes no
|
||||||
|
behaviour, only the shape of the call.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
`examples/basic-app` and both test apps need one pass each:
|
||||||
|
|
||||||
|
1. Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||||
|
`wrnexus.config.ts` — seven lines per app.
|
||||||
|
2. `packages/cli/src/create.ts` stops scaffolding those keys, so new apps get a shorter config.
|
||||||
|
|
||||||
|
No `.wrn` source changes. Nothing in this spec alters page syntax.
|
||||||
|
|
||||||
|
The removed `0.2.x`–`0.7.x` migrations mean a project below `0.8.0` can no longer be upgraded by
|
||||||
|
`wrnexus update`. No such project exists, and rescuing one would be a manual job either way.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **The `"legacy"` runtime removal is behaviour-preserving**: an unmarked function still appears in
|
||||||
|
both the browser and server modules. This is the assertion most worth writing, because it is the
|
||||||
|
only removal that could silently change output.
|
||||||
|
- **Config rejects the removed keys** rather than ignoring them, so a stale config fails loudly with
|
||||||
|
a message naming the key. A silently-ignored key would leave someone believing a flag still
|
||||||
|
applies.
|
||||||
|
- **`create.ts` scaffolds a config without them**, asserted against the generated file.
|
||||||
|
- **`wrnexus update` still runs** with the pre-`0.8.0` migrations gone, and reports correctly for an
|
||||||
|
app already at the current version.
|
||||||
|
- **`examples/basic-app` builds and its suite passes** after its config is trimmed — the end-to-end
|
||||||
|
guard that nothing depended on the removed surface.
|
||||||
|
- **The full gate** (`bun run check:production`) passes, including the editor bundles, which embed
|
||||||
|
the compiler and must be rebuilt after `FunctionRuntime` changes.
|
||||||
|
|
||||||
|
## What we give up
|
||||||
|
|
||||||
|
Deleting `compatibilityDate` and `frameworkBehaviour` removes the standard escape hatch for changing
|
||||||
|
a default after going public — the mechanism that lets an existing app keep old behaviour by pinning
|
||||||
|
a date. Today it is wired to nothing, so it protects nobody, and an unused mechanism rots rather
|
||||||
|
than matures. If a gate is needed later it can be reintroduced deliberately, against a real
|
||||||
|
behaviour change, instead of being carried empty. This is a considered trade rather than a free
|
||||||
|
deletion.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Carrying projects to the new syntax with `wrnexus update` — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** Migrations that take an existing project from today's syntax to the syntax left by the
|
||||||
|
cleanup and `apis { }` specs.
|
||||||
|
|
||||||
|
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md` and
|
||||||
|
`2026-08-19-apis-block-design.md`. Both define the target this migrates to, so both must land first.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
After the two preceding specs, every existing project is written in a syntax the framework no longer
|
||||||
|
accepts. One `wrnexus update` should carry a project across — config keys removed, `api` blocks
|
||||||
|
moved into `apis { }`, mode-scoped helpers relocated — and, where it cannot do that safely, say so
|
||||||
|
precisely instead of guessing.
|
||||||
|
|
||||||
|
## What already exists
|
||||||
|
|
||||||
|
This is an extension of working machinery, not a new subsystem:
|
||||||
|
|
||||||
|
- `Migration { version, id, description, apply(ctx) }`, run when `from < version <= to`.
|
||||||
|
- `MigrationCtx` carries `appRoot`, `from`, `to`, **`dryRun`**, a report, and a logger.
|
||||||
|
- `MigrationReport` already separates `changedAutomatically`, `needsReview`, `parseFailures`,
|
||||||
|
`ambiguousFunctions`, and `unresolvedImports`.
|
||||||
|
- `update.ts` already imports `parse` and `formatWrn` from `@wrnexus/syntax`, so parsing a `.wrn`
|
||||||
|
file, transforming it, and re-emitting formatted source is an established pattern here.
|
||||||
|
|
||||||
|
The report's shape matters: it was built around the idea that some changes are safe to make and
|
||||||
|
others must be handed back to a human. That distinction is the backbone of this spec.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Migrating projects below `0.8.0`. The cleanup spec removes those migrations; no such project
|
||||||
|
exists.
|
||||||
|
- Rewriting application logic. Only the constructs these specs changed.
|
||||||
|
|
||||||
|
## The safety contract
|
||||||
|
|
||||||
|
**A file is transformed correctly, or it is left untouched and reported.** There is no third
|
||||||
|
outcome. Concretely:
|
||||||
|
|
||||||
|
1. Parse the file. A parse failure records the path in `parseFailures` and moves on — the file is
|
||||||
|
never partially rewritten.
|
||||||
|
2. Transform, then re-emit through `formatWrn`.
|
||||||
|
3. If any part of a file's transform cannot be completed, **the whole file is skipped** and recorded
|
||||||
|
in `needsReview` with the reason and the construct involved.
|
||||||
|
|
||||||
|
Running the migration twice must be a no-op: every transform detects already-migrated input and
|
||||||
|
does nothing. Dry-run must report exactly what a real run would change.
|
||||||
|
|
||||||
|
## The migrations
|
||||||
|
|
||||||
|
### 1. Remove the dead config keys
|
||||||
|
|
||||||
|
Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||||
|
`wrnexus.config.ts`. Mechanical, fully automatic, and safe because none of them was ever read.
|
||||||
|
|
||||||
|
### 2. `ssr { api … }` / `client { api … }` → `apis { … }`
|
||||||
|
|
||||||
|
Move each `api` entry into a page-level `apis { }` block, dropping the mode. Sectioned bodies —
|
||||||
|
those already using `request` / `response` / `error` — carry across unchanged, because the payload
|
||||||
|
is already bound to `data`.
|
||||||
|
|
||||||
|
Fully automatic. If a page has entries in both an `ssr` and a `client` block sharing a name, that is
|
||||||
|
a duplicate under the new rules and the **file is skipped and reported**, since choosing which one
|
||||||
|
survives is a decision about intent.
|
||||||
|
|
||||||
|
### 3. `ssr { functions { … } }` → `functions { shared function … }`
|
||||||
|
|
||||||
|
Relocate mode-scoped helpers to the page-level `functions { }` block with the `shared` modifier.
|
||||||
|
Automatic. If the page already has a function of the same name, the file is skipped and reported.
|
||||||
|
|
||||||
|
### 4. Legacy bare-body `api` blocks — **needs review, not automatic**
|
||||||
|
|
||||||
|
This is the one transform that cannot be done safely, and the spec is explicit about it rather than
|
||||||
|
attempting a best effort.
|
||||||
|
|
||||||
|
A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare
|
||||||
|
identifiers:
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
api ssrUsers GET /api/users/ssr {
|
||||||
|
return userNames(users)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sectioned form binds the payload to `data`, so this must become `data.users`. But **which free
|
||||||
|
identifiers are payload fields is not knowable from the source.** In the example, `users` comes from
|
||||||
|
the response and `userNames` is a page helper — and nothing in the file distinguishes them. The
|
||||||
|
response shape belongs to the route, and the route may not even be typed.
|
||||||
|
|
||||||
|
A migration that guessed would produce code that compiles and is wrong: `data.userNames(...)` or an
|
||||||
|
untouched `users` that silently resolves to `undefined`. That is precisely the silent-wrong-answer
|
||||||
|
failure this project keeps paying for.
|
||||||
|
|
||||||
|
So: legacy bare-body blocks are **detected, reported in `needsReview` with the file, the block name,
|
||||||
|
and the free identifiers found**, and left untouched. The report tells the author exactly what to
|
||||||
|
decide. `wrnexus update` prints a short explanation of why this one is manual.
|
||||||
|
|
||||||
|
### 5. Deprecated `@wrnexus/auth` options
|
||||||
|
|
||||||
|
Only if the cleanup spec's optional auth section is included. Rename call sites of the superseded
|
||||||
|
options. Automatic where the rename is unambiguous; reported otherwise.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
All of these attach to the release that ships the breaking change. After the cleanup spec the
|
||||||
|
migration floor is `0.8.0`, so the list is short and every entry is reachable.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
At the end of a run the command prints, in this order: what it changed, what needs review and why,
|
||||||
|
and what failed to parse. A run with anything in `needsReview` or `parseFailures` exits non-zero, so
|
||||||
|
a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Each migration gets a fixture project and three assertions: the transform produces the expected
|
||||||
|
source, running it a second time changes nothing, and a dry run reports the same set without writing.
|
||||||
|
|
||||||
|
- **Config removal** — keys gone, rest of the config untouched.
|
||||||
|
- **`api` relocation** — a page with both `ssr` and `client` api blocks lands in one `apis { }`;
|
||||||
|
entries keep their names, methods, paths, and sections.
|
||||||
|
- **Name collision across modes** — the file is skipped and reported, not silently merged.
|
||||||
|
- **Mode functions** — relocated with the `shared` modifier; a name collision skips and reports.
|
||||||
|
- **Legacy bare body** — reported in `needsReview` with the block name and free identifiers, and the
|
||||||
|
file is byte-identical afterwards. This is the most important test in the spec: it pins that the
|
||||||
|
migration does _not_ attempt the rewrite.
|
||||||
|
- **Parse failure** — a malformed `.wrn` is recorded in `parseFailures` and left untouched.
|
||||||
|
- **Exit code** — non-zero when anything needs review.
|
||||||
|
- **End to end** — `examples/basic-app` migrated by the command alone, then built and tested. If the
|
||||||
|
framework's own example cannot be migrated by the tool, the tool is not finished.
|
||||||
|
|
||||||
|
## What this does not promise
|
||||||
|
|
||||||
|
Automated source rewriting cannot be promised as "perfect". What is promised is bounded: every file
|
||||||
|
is either correctly transformed or untouched and named in the report, with the reason. Nothing is
|
||||||
|
half-rewritten, and nothing is guessed. The legacy bare-body case is deliberately manual because a
|
||||||
|
correct automatic answer does not exist.
|
||||||
@@ -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 };
|
||||||
+709
-234
File diff suppressed because it is too large
Load Diff
@@ -306,6 +306,54 @@ const BLOCK_COMPLETIONS = [
|
|||||||
"}",
|
"}",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "apis",
|
||||||
|
detail: "Page-level API call declarations block",
|
||||||
|
documentation:
|
||||||
|
"Declare the API calls available to this page as named entries, each callable via api.<name>() from functions and the `api=` view binding.",
|
||||||
|
snippet: [
|
||||||
|
"apis {",
|
||||||
|
" ${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||||
|
" request {",
|
||||||
|
" body {",
|
||||||
|
" ${4:name}?: ${5:string}",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" response {",
|
||||||
|
" return ${6:data}",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" error {",
|
||||||
|
" return ${7:null}",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "apis entry",
|
||||||
|
detail: "Single API call declaration",
|
||||||
|
documentation:
|
||||||
|
"Declare a single named API call inside an apis { } block: <name> <METHOD> <path> { request/response/error }.",
|
||||||
|
snippet: [
|
||||||
|
"${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||||
|
" request {",
|
||||||
|
" body {",
|
||||||
|
" ${4:name}?: ${5:string}",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" response {",
|
||||||
|
" return ${6:data}",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" error {",
|
||||||
|
" return ${7:null}",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "persist",
|
label: "persist",
|
||||||
detail: "Include-only store persistence",
|
detail: "Include-only store persistence",
|
||||||
@@ -570,6 +618,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 +717,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 +792,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: d0ca0feee6516ae43e902e6aea0424bc5c3d05790708eb8a8c68c98162eb99bb
|
||||||
// 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 };
|
||||||
|
|||||||
+22896
-158
File diff suppressed because one or more lines are too long
@@ -113,10 +113,10 @@
|
|||||||
"include": "#action-block"
|
"include": "#action-block"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": "#mode-block"
|
"include": "#api-block"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": "#api-block"
|
"include": "#apis-block"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": "#functions-block"
|
"include": "#functions-block"
|
||||||
@@ -513,8 +513,8 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mode-block": {
|
"apis-block": {
|
||||||
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
|
"begin": "\\b(apis)\\b\\s*(\\{)",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "keyword.control.wrn"
|
"name": "keyword.control.wrn"
|
||||||
@@ -534,21 +534,38 @@
|
|||||||
"include": "#comments"
|
"include": "#comments"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
"begin": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
||||||
|
"beginCaptures": {
|
||||||
|
"1": {
|
||||||
|
"name": "entity.name.function.wrn"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "constant.language.http-method.wrn"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"name": "string.unquoted.route.wrn"
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"name": "punctuation.definition.block.begin.wrn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"end": "\\}",
|
||||||
|
"endCaptures": {
|
||||||
|
"0": {
|
||||||
|
"name": "punctuation.definition.block.end.wrn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"include": "#comments"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"begin": "\\b(request|response|error)\\b\\s*(\\{)",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "keyword.control.wrn"
|
"name": "keyword.control.wrn"
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"name": "entity.name.function.wrn"
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"name": "constant.language.http-method.wrn"
|
|
||||||
},
|
|
||||||
"4": {
|
|
||||||
"name": "string.unquoted.route.wrn"
|
|
||||||
},
|
|
||||||
"5": {
|
|
||||||
"name": "punctuation.definition.block.begin.wrn"
|
"name": "punctuation.definition.block.begin.wrn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -569,7 +586,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": "#functions-block"
|
"include": "#ts-braces"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "source.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
translate(lineDelta, characterDelta) {
|
||||||
|
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||||
|
}
|
||||||
|
isEqual(other) {
|
||||||
|
return this.line === other.line && this.character === other.character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Selection {
|
||||||
|
constructor(active) {
|
||||||
|
this.active = active;
|
||||||
|
this.anchor = active;
|
||||||
|
this.isEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnippetString {
|
||||||
|
constructor(value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changeListener = null;
|
||||||
|
const host = {
|
||||||
|
Position,
|
||||||
|
Selection,
|
||||||
|
SnippetString,
|
||||||
|
workspace: {
|
||||||
|
onDidChangeTextDocument(listener) {
|
||||||
|
changeListener = listener;
|
||||||
|
return { dispose() {} };
|
||||||
|
},
|
||||||
|
getConfiguration() {
|
||||||
|
return { get: (_key, fallback) => fallback };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { activeTextEditor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost(host);
|
||||||
|
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the handler the way VS Code does: the document has already been
|
||||||
|
* updated and the carets moved by the time the change event fires.
|
||||||
|
*/
|
||||||
|
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||||
|
const inserted = [];
|
||||||
|
const asked = [];
|
||||||
|
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||||
|
const editor = {
|
||||||
|
document,
|
||||||
|
selections: carets.map((caret) => new Selection(caret)),
|
||||||
|
insertSnippet(snippet, positions) {
|
||||||
|
inserted.push({ value: snippet.value, positions });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
editor.selection = editor.selections[0];
|
||||||
|
host.window.activeTextEditor = editor;
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
sendRequest(_method, params) {
|
||||||
|
asked.push(params.position);
|
||||||
|
return Promise.resolve(snippetFor(params.position));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inserted,
|
||||||
|
asked,
|
||||||
|
fire: () =>
|
||||||
|
changeListener({
|
||||||
|
document,
|
||||||
|
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||||
|
contentChanges: carets.map(() => ({
|
||||||
|
text: typed,
|
||||||
|
rangeLength: 0,
|
||||||
|
range: { start: new Position(0, 0) },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("closes the tag at a single caret", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.equal(run.inserted[0].value, "$0</div>");
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[[1, 8]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the tag at every caret in one insertion", async () => {
|
||||||
|
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||||
|
// sequentially would collapse the selection to the first snippet.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||||
|
snippetFor: () => "$0</div>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.asked.length, 3);
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[1, 8],
|
||||||
|
[2, 8],
|
||||||
|
[3, 8],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||||
|
// Every contentChange above reports (0, 0). Using those would query and
|
||||||
|
// insert at the wrong offsets once more than one caret is on a line.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(4, 12), new Position(9, 3)],
|
||||||
|
snippetFor: () => "$0</p>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
run.asked.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[4, 12],
|
||||||
|
[9, 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when the carets want different closing tags", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when any caret has no tag to close", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a replaced selection", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await changeListener({
|
||||||
|
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||||
|
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost({
|
||||||
|
Position: class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Range: class Range {
|
||||||
|
constructor(start, end) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItem: class CompletionItem {
|
||||||
|
constructor(label, kind) {
|
||||||
|
this.label = label;
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItemKind: {
|
||||||
|
Event: 23,
|
||||||
|
Property: 10,
|
||||||
|
Function: 12,
|
||||||
|
Keyword: 14,
|
||||||
|
Variable: 13,
|
||||||
|
},
|
||||||
|
SnippetString: class SnippetString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MarkdownString: class MarkdownString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div>hello</div>
|
||||||
|
}
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a markup offset is inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a functions-block offset is not inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("<div");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: "<div>hello</div>" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 2, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert.equal(result.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("function go");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: " function go() {}" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 5, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||||
|
});
|
||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
// These extraction helpers are pure, but their module also registers VS Code
|
// These extraction helpers are pure, but their module also registers VS Code
|
||||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({});
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") return {};
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
test("extracts dynamic route params from filename", () => {
|
test("extracts dynamic route params from filename", () => {
|
||||||
const document = {
|
const document = {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") {
|
|
||||||
return {
|
|
||||||
Diagnostic: class Diagnostic {
|
Diagnostic: class Diagnostic {
|
||||||
constructor(range, message, severity) {
|
constructor(range, message, severity) {
|
||||||
this.range = range;
|
this.range = range;
|
||||||
@@ -22,10 +19,7 @@ Module._load = function load(request, parent, isMain) {
|
|||||||
this.end = end;
|
this.end = end;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
findTopLevelDeclaration,
|
findTopLevelDeclaration,
|
||||||
maskLeadingTrivia,
|
maskLeadingTrivia,
|
||||||
@@ -34,7 +28,7 @@ const {
|
|||||||
validateLayoutUsage,
|
validateLayoutUsage,
|
||||||
validateRootMembers,
|
validateRootMembers,
|
||||||
} = require("../src/diagnostics");
|
} = require("../src/diagnostics");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
function mockDocument() {
|
function mockDocument() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { readFileSync } = require("node:fs");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
|
||||||
|
const grammarPath = join(__dirname, "../syntaxes/wrn.tmLanguage.json");
|
||||||
|
const grammar = JSON.parse(readFileSync(grammarPath, "utf8"));
|
||||||
|
|
||||||
|
test("the grammar knows the apis block", () => {
|
||||||
|
assert.ok(grammar.repository["apis-block"], "apis should appear as a block keyword");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client keeps its highlighting where it is still valid", () => {
|
||||||
|
// client state {}, runtime = "client", and client function all survive.
|
||||||
|
// Only the client {} data block was removed.
|
||||||
|
assert.match(JSON.stringify(grammar), /client/, "client must still be matched");
|
||||||
|
assert.match(
|
||||||
|
JSON.stringify(grammar),
|
||||||
|
/shared/,
|
||||||
|
"the shared function modifier must still be matched",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Structural assertions: these fail if the rule they name is deleted, ---
|
||||||
|
// --- even though the substrings "apis"/"client" remain elsewhere in the ---
|
||||||
|
// --- file (e.g. inside unrelated pattern names or comments). ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vscode-textmate is not a dependency of this repo (confirmed via
|
||||||
|
* `require.resolve`), so real tokenization is unavailable here. Instead we
|
||||||
|
* pull each rule's own `begin`/`match` regex out of the parsed grammar
|
||||||
|
* object and exercise it directly with the JS regex engine. The grammar's
|
||||||
|
* patterns use only `\b`, character classes, and `(?<=...)` lookbehind --
|
||||||
|
* all supported by native JS regexes -- so this is a faithful proxy for
|
||||||
|
* "does this specific rule match this specific source line" without
|
||||||
|
* needing an Oniguruma-backed tokenizer.
|
||||||
|
*/
|
||||||
|
function ruleByTopLevelBlockName(name) {
|
||||||
|
const entry = grammar.repository[name];
|
||||||
|
assert.ok(entry, `repository.${name} should exist`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topLevelBlockIncludes(name) {
|
||||||
|
return grammar.repository.blocks.patterns.some((p) => p.include === `#${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("blocks includes the apis-block pattern", () => {
|
||||||
|
assert.ok(topLevelBlockIncludes("apis-block"), "#blocks must include #apis-block");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the apis-block begin pattern matches the container keyword", () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("apis-block");
|
||||||
|
const beginRe = new RegExp(rule.begin);
|
||||||
|
assert.ok(beginRe.test("apis {"), "apis-block begin should match 'apis {'");
|
||||||
|
assert.ok(!beginRe.test("ssr {"), "apis-block begin must not match 'ssr {'");
|
||||||
|
assert.ok(!beginRe.test("client {"), "apis-block begin must not match 'client {'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the apis-block declares an entry pattern for <name> <METHOD> <path>", () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("apis-block");
|
||||||
|
const entryPattern = (rule.patterns || []).find(
|
||||||
|
(p) => p.name === "apis-entry" || (p.begin && /GET\|POST/.test(p.begin)),
|
||||||
|
);
|
||||||
|
assert.ok(entryPattern, "apis-block should contain an entry declaration pattern");
|
||||||
|
const entryRe = new RegExp(entryPattern.begin || entryPattern.match);
|
||||||
|
assert.ok(
|
||||||
|
entryRe.test("searchUsers POST /api/users {"),
|
||||||
|
"the entry pattern should match '<name> <METHOD> <path> {'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ssr/client data-block pattern (mode-block) is gone", () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
grammar.repository["mode-block"],
|
||||||
|
undefined,
|
||||||
|
"mode-block (the removed ssr {}/client {} data-block rule) must be deleted from the repository",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!topLevelBlockIncludes("mode-block"),
|
||||||
|
"#blocks must no longer include the removed #mode-block pattern",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Regression coverage: constructs that must NOT be broken by this change ---
|
||||||
|
|
||||||
|
test("REGRESSION: client state {} still highlights via grouped-state-block", () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("grouped-state-block");
|
||||||
|
const beginRe = new RegExp(rule.begin);
|
||||||
|
assert.ok(
|
||||||
|
beginRe.test("client state {"),
|
||||||
|
"grouped-state-block must still match 'client state {'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REGRESSION: runtime = "client" still highlights via execution-decl', () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("execution-decl");
|
||||||
|
const beginRe = new RegExp(rule.begin);
|
||||||
|
assert.ok(
|
||||||
|
beginRe.test('runtime = "client"'),
|
||||||
|
"execution-decl must still match 'runtime = \"client\"'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("REGRESSION: the client function modifier still highlights via functions-block", () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("functions-block");
|
||||||
|
const matchRe = new RegExp(
|
||||||
|
rule.patterns.find(
|
||||||
|
(p) => p.captures && p.captures["1"]?.name === "storage.modifier.runtime.wrn",
|
||||||
|
).match,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
matchRe.test("client async function go(): Promise<void> {"),
|
||||||
|
"functions-block must still recognize the 'client' runtime modifier on a function",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("REGRESSION: the client function modifier also highlights via v060-keywords", () => {
|
||||||
|
const rule = ruleByTopLevelBlockName("v060-keywords");
|
||||||
|
const modifierPattern = rule.patterns.find((p) => p.name === "storage.modifier.runtime.wrn");
|
||||||
|
assert.ok(modifierPattern, "v060-keywords should have a storage.modifier.runtime.wrn rule");
|
||||||
|
const matchRe = new RegExp(modifierPattern.match);
|
||||||
|
assert.ok(
|
||||||
|
matchRe.test("client function go"),
|
||||||
|
"v060-keywords must still recognize 'client' before 'function'",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
matchRe.test("client state"),
|
||||||
|
"v060-keywords must still recognize 'client' before 'state'",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# WrNexus app - instructions for AI coding assistants
|
||||||
|
|
||||||
|
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||||
|
or features, follow the framework conventions below. WrNexus is private and not in
|
||||||
|
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||||
|
|
||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wrn-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) {
|
||||||
|
client.send({ type: "system", text: "connected" });
|
||||||
|
},
|
||||||
|
onMessage(client, msg) {
|
||||||
|
client.room.broadcast({ type: "message", data: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
process: async ({ entryPath, mode }) => /* Tailwind */ "",
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
sans: '"Inter", system-ui, sans-serif',
|
||||||
|
google: [{ family: "Inter", weights: [400, 600] }],
|
||||||
|
},
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -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,49 @@
|
|||||||
|
page ApiBlockDemo {
|
||||||
|
state nameFilter = "a"
|
||||||
|
state found = ""
|
||||||
|
state failed = ""
|
||||||
|
|
||||||
|
apis {
|
||||||
|
searchDirectory POST /api/directory {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load server serverSearch {
|
||||||
|
const users = await api.searchDirectory({ name: "a" })
|
||||||
|
return { names: users.map((user) => user.name).join(", ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Async source="serverSearch">
|
||||||
|
<Loading><p class="server-found">Loading…</p></Loading>
|
||||||
|
<Success data="serverSearch"><p class="server-found">{serverSearch.names}</p></Success>
|
||||||
|
<Error error="error"><p class="server-found">Failed: {error.message}</p></Error>
|
||||||
|
</Async>
|
||||||
|
<p class="bound" api="searchDirectory({ name: 'a' })">loading</p>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,37 +18,32 @@ page Hello {
|
|||||||
canonical = "/hello"
|
canonical = "/hello"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSR data bindings run on the server before the HTML is sent.
|
// API calls used by this page. `ssrUsers` is bound in the initial render
|
||||||
// The API itself lives in app/api/users/ssr.ts; this block only calls it
|
// (its endpoint lives in app/api/users/ssr.ts); `csrUsers` is bound after
|
||||||
// and renders the response into HTML.
|
// hydration in the browser (app/api/users/csr.ts). Both just call the same
|
||||||
ssr {
|
// users endpoint shape, so the response handling looks the same.
|
||||||
functions {
|
apis {
|
||||||
function userNames(users) {
|
ssrUsers GET /api/users/ssr {
|
||||||
return users.map((user) => user.name).join(", ")
|
response {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api ssrUsers GET /api/users/ssr {
|
|
||||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||||
session.set("lastHelloVisit", visits)
|
session.set("lastHelloVisit", visits)
|
||||||
return `${userNames(users)} - visit ${visits}`
|
return `${userNames(data.users)} - visit ${visits}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client data bindings hydrate after the first paint. The browser only sees
|
csrUsers GET /api/users/csr {
|
||||||
// an opaque data-wrnexus-csr id; WrNexus calls app/api/users/csr.ts on the server.
|
response {
|
||||||
client {
|
|
||||||
functions {
|
|
||||||
function userNames(users) {
|
|
||||||
return users.map((user) => user.name).join(", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api csrUsers GET /api/users/csr {
|
|
||||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||||
session.set("lastClientLabel", label)
|
session.set("lastClientLabel", label)
|
||||||
return `${userNames(users)} - ${label}`
|
return `${userNames(data.users)} - ${label}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
shared function userNames(users) {
|
||||||
|
return users.map((user) => user.name).join(", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
{
|
{
|
||||||
"name": "basic-app",
|
"name": "basic-app",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
|
"wrnexus": {
|
||||||
|
"version": "0.9.0"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -23,12 +26,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/test": "workspace:*",
|
"@wrnexus/test": "workspace:*",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wrn-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||||
|
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||||
|
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -13,8 +13,6 @@ import type { AppConfig } from "@wrnexus/styles";
|
|||||||
* framework-level headers and optional CORS.
|
* framework-level headers and optional CORS.
|
||||||
*/
|
*/
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
frameworkBehaviour: 1,
|
|
||||||
compatibilityDate: "2026-08-02",
|
|
||||||
head: [
|
head: [
|
||||||
// --- Use a CSS framework via CDN (uncomment one) ---
|
// --- Use a CSS framework via CDN (uncomment one) ---
|
||||||
// Bootstrap:
|
// Bootstrap:
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Northstar CRM example
|
||||||
|
|
||||||
|
A complete WrNexus example covering public pages, SQLite migrations and seed data, SQL-backed signup/login/session authentication, database-backed roles and permissions, protected pages, and permission-checked CRM APIs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:migrate
|
||||||
|
bun run db:seed
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `/signup`, create an account, and the signup hook assigns the `sales-rep` role. The user is automatically signed in and redirected to `/dashboard`. Auth tables come from the `@wrnexus/auth` plugin migrations; CRM and authorization tables come from `app/db/migrations/001_crm.sql`.
|
||||||
|
|
||||||
|
The seed records use the placeholder owner `demo-owner` to demonstrate repeatable data. To attach them to a registered user, update their `owner_id` to that user's ID.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { can } from "@wrnexus/authz";
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
import type { Context } from "@wrnexus/core";
|
||||||
|
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||||
|
|
||||||
|
const userId = (ctx: Context) => String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||||
|
|
||||||
|
export async function GET(ctx: Context): Promise<Response> {
|
||||||
|
if (!(await can(ctx, "contact:read")))
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
await ensureCrmWorkspace(userId(ctx));
|
||||||
|
const rows = await getDb().all(
|
||||||
|
"SELECT id,name,email,company,phone,status,created_at FROM crm_contacts WHERE owner_id = ? ORDER BY name",
|
||||||
|
[userId(ctx)],
|
||||||
|
);
|
||||||
|
return Response.json({ contacts: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(ctx: Context): Promise<Response> {
|
||||||
|
if (!(await can(ctx, "contact:write")))
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
const body = (await ctx.req.json()) as Record<string, unknown>;
|
||||||
|
const name = String(body.name ?? "").trim();
|
||||||
|
const email = String(body.email ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return Response.json({ error: "A name and valid email are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = await getDb().exec(
|
||||||
|
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||||
|
[userId(ctx), name, email, String(body.company ?? ""), String(body.phone ?? ""), "lead"],
|
||||||
|
);
|
||||||
|
return Response.json({ id: Number(result.lastInsertId), name, email }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { can } from "@wrnexus/authz";
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
import type { Context } from "@wrnexus/core";
|
||||||
|
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||||
|
|
||||||
|
export async function GET(ctx: Context): Promise<Response> {
|
||||||
|
if (!(await can(ctx, "crm:dashboard")))
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||||
|
await ensureCrmWorkspace(owner);
|
||||||
|
const metrics = await getDb().one<{
|
||||||
|
pipeline_value_cents: number;
|
||||||
|
open_opportunities: number;
|
||||||
|
contacts: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE((SELECT SUM(value_cents) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')), 0) pipeline_value_cents,
|
||||||
|
(SELECT COUNT(*) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')) open_opportunities,
|
||||||
|
(SELECT COUNT(*) FROM crm_contacts WHERE owner_id = ?) contacts`,
|
||||||
|
[owner, owner, owner],
|
||||||
|
);
|
||||||
|
return Response.json({
|
||||||
|
metrics: {
|
||||||
|
pipelineValue: new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(metrics?.pipeline_value_cents ?? 0) / 100),
|
||||||
|
openOpportunities: Number(metrics?.open_opportunities ?? 0),
|
||||||
|
contacts: Number(metrics?.contacts ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { can } from "@wrnexus/authz";
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
import type { Context } from "@wrnexus/core";
|
||||||
|
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||||
|
|
||||||
|
export async function GET(ctx: Context): Promise<Response> {
|
||||||
|
if (!(await can(ctx, "deal:read"))) return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||||
|
await ensureCrmWorkspace(owner);
|
||||||
|
const deals = await getDb().all(
|
||||||
|
"SELECT d.id,d.title,d.value_cents,d.stage,d.close_date,c.name contact_name FROM crm_deals d LEFT JOIN crm_contacts c ON c.id=d.contact_id WHERE d.owner_id=? ORDER BY d.updated_at DESC",
|
||||||
|
[owner],
|
||||||
|
);
|
||||||
|
return Response.json({ deals });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineAuthz } from "@wrnexus/authz";
|
||||||
|
|
||||||
|
export default defineAuthz({
|
||||||
|
permissions: {
|
||||||
|
"crm:dashboard": { title: "View dashboard" },
|
||||||
|
"contact:read": { title: "View contacts" },
|
||||||
|
"contact:write": { title: "Create and edit contacts" },
|
||||||
|
"contact:delete": { title: "Delete contacts", risk: "high" },
|
||||||
|
"deal:read": { title: "View deals" },
|
||||||
|
"deal:write": { title: "Create and edit deals" },
|
||||||
|
"admin:access": { title: "Manage CRM access", risk: "high" },
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
viewer: ["crm:dashboard", "contact:read", "deal:read"],
|
||||||
|
"sales-rep": ["role:viewer", "contact:write", "deal:write"],
|
||||||
|
manager: ["role:sales-rep", "contact:delete"],
|
||||||
|
admin: ["role:manager", "admin:access"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- +up
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
company TEXT NOT NULL DEFAULT '',
|
||||||
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'lead' CHECK (status IN ('lead', 'customer', 'inactive')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS crm_contacts_owner_idx ON crm_contacts(owner_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_owner_email_uq ON crm_contacts(owner_id, email);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_deals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
contact_id INTEGER REFERENCES crm_contacts(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
value_cents INTEGER NOT NULL DEFAULT 0 CHECK (value_cents >= 0),
|
||||||
|
stage TEXT NOT NULL DEFAULT 'qualified' CHECK (stage IN ('qualified', 'proposal', 'won', 'lost')),
|
||||||
|
close_date TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS crm_deals_owner_idx ON crm_deals(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS crm_deals_contact_idx ON crm_deals(contact_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_activities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('contact', 'deal', 'account')),
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS crm_activities_entity_idx ON crm_activities(entity_type, entity_id);
|
||||||
|
|
||||||
|
-- Database-backed authorization assignments.
|
||||||
|
CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject_id VARCHAR(255) NOT NULL,
|
||||||
|
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
role VARCHAR(255) NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject_id VARCHAR(255) NOT NULL,
|
||||||
|
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
permission VARCHAR(255) NOT NULL,
|
||||||
|
effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +down
|
||||||
|
DROP TABLE IF EXISTS _wrn_authz_grant;
|
||||||
|
DROP TABLE IF EXISTS _wrn_authz_assignment;
|
||||||
|
DROP TABLE IF EXISTS crm_activities;
|
||||||
|
DROP TABLE IF EXISTS crm_deals;
|
||||||
|
DROP TABLE IF EXISTS crm_contacts;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Db } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export default async function seed(db: Db): Promise<void> {
|
||||||
|
const owner = "demo-owner";
|
||||||
|
await db.exec("DELETE FROM crm_activities");
|
||||||
|
await db.exec("DELETE FROM crm_deals");
|
||||||
|
await db.exec("DELETE FROM crm_contacts");
|
||||||
|
await db.exec(
|
||||||
|
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||||
|
[owner, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
|
||||||
|
);
|
||||||
|
await db.exec(
|
||||||
|
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||||
|
[owner, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
|
||||||
|
);
|
||||||
|
const contact = await db.one<{ id: number }>("SELECT id FROM crm_contacts WHERE email = ?", [
|
||||||
|
"ada@example.test",
|
||||||
|
]);
|
||||||
|
await db.exec(
|
||||||
|
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
|
||||||
|
[owner, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createAuthEngine, SqlAuthStore, type AuthStore } from "@wrnexus/auth";
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
import { dbPermissionStore } from "@wrnexus/authz/db";
|
||||||
|
import { ensureCrmWorkspace } from "./workspace.ts";
|
||||||
|
|
||||||
|
// Config is imported before the runtime opens its configured database. This
|
||||||
|
// forwarding store resolves getDb() only when an auth operation actually runs.
|
||||||
|
const store = new Proxy({} as AuthStore, {
|
||||||
|
get(_target, property) {
|
||||||
|
const value = Reflect.get(new SqlAuthStore(getDb()), property);
|
||||||
|
return typeof value === "function" ? value.bind(new SqlAuthStore(getDb())) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const auth = createAuthEngine({
|
||||||
|
store,
|
||||||
|
secret: process.env.AUTH_SECRET ?? "northstar-crm-development-secret-change-me",
|
||||||
|
issuer: "Northstar CRM",
|
||||||
|
onSignedIn(ctx, returnTo) {
|
||||||
|
const destination =
|
||||||
|
returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/dashboard";
|
||||||
|
return Response.redirect(new URL(destination, ctx.url), 303);
|
||||||
|
},
|
||||||
|
onSignedOut(ctx) {
|
||||||
|
return Response.redirect(new URL("/", ctx.url), 303);
|
||||||
|
},
|
||||||
|
async onSuccessfulSignUp(_ctx, user) {
|
||||||
|
await dbPermissionStore(getDb()).assignRole(user.id, "sales-rep");
|
||||||
|
await ensureCrmWorkspace(user.id);
|
||||||
|
return { autoSignIn: true, redirectTo: "/dashboard" };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
/** Give every real CRM user a useful workspace on first access. */
|
||||||
|
export async function ensureCrmWorkspace(ownerId: string): Promise<void> {
|
||||||
|
if (!ownerId) return;
|
||||||
|
const db = getDb();
|
||||||
|
await db.exec(
|
||||||
|
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||||
|
[ownerId, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
|
||||||
|
);
|
||||||
|
await db.exec(
|
||||||
|
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||||
|
[ownerId, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
|
||||||
|
);
|
||||||
|
const existingDeal = await db.one<{ count: number }>(
|
||||||
|
"SELECT COUNT(*) count FROM crm_deals WHERE owner_id = ?",
|
||||||
|
[ownerId],
|
||||||
|
);
|
||||||
|
if (Number(existingDeal?.count ?? 0) === 0) {
|
||||||
|
const contact = await db.one<{ id: number }>(
|
||||||
|
"SELECT id FROM crm_contacts WHERE owner_id = ? AND email = ?",
|
||||||
|
[ownerId, "ada@example.test"],
|
||||||
|
);
|
||||||
|
await db.exec(
|
||||||
|
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
|
||||||
|
[ownerId, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
import { dbPermissionStore } from "@wrnexus/authz/db";
|
||||||
|
import type { Middleware } from "@wrnexus/core";
|
||||||
|
|
||||||
|
// Production imports middleware before it opens configured databases. Resolve
|
||||||
|
// the SQL-backed store on the first request, after runtime initialization.
|
||||||
|
const middleware: Middleware = (ctx, next) =>
|
||||||
|
authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) })(ctx, next);
|
||||||
|
|
||||||
|
export default middleware;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { requireAuth } from "@wrnexus/auth";
|
||||||
|
import { can } from "@wrnexus/authz";
|
||||||
|
import type { Middleware } from "@wrnexus/core";
|
||||||
|
|
||||||
|
const guard = requireAuth({ loginPath: "/login" });
|
||||||
|
const protectedPrefixes = ["/dashboard", "/contacts", "/deals", "/admin"];
|
||||||
|
|
||||||
|
const middleware: Middleware = (ctx, next) => {
|
||||||
|
if (!protectedPrefixes.some((prefix) => ctx.url.pathname.startsWith(prefix))) return next();
|
||||||
|
return guard(ctx, async () => {
|
||||||
|
if (ctx.url.pathname.startsWith("/admin") && !(await can(ctx, "admin:access"))) {
|
||||||
|
return Response.redirect(new URL("/forbidden", ctx.url), 303);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default middleware;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
page Admin {
|
||||||
|
state user = ctx.user
|
||||||
|
seo { title = "Administration" }
|
||||||
|
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin" aria-current="page">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Access administration</h1><p>Manage roles and protect high-risk operations.</p></div></header><section class="crm-panel"><span class="crm-eyebrow">Role-based access</span><h2>Signed in as {user.displayName || user.username}</h2><p class="crm-hero-copy">This route requires the <strong>admin:access</strong> permission. Assign manager and administrator roles through the database-backed authorization store.</p></section></section></main> }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
page Contacts {
|
||||||
|
apis { listContacts GET /api/contacts { response { return data.contacts } } }
|
||||||
|
load server contacts { return await api.listContacts() }
|
||||||
|
seo { title = "Contacts" }
|
||||||
|
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts" aria-current="page">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Contacts</h1><p>Every relationship, scoped securely to its owner.</p></div><a class="crm-button" href="mailto:sales@example.test">New contact</a></header><section class="crm-panel"><span class="crm-eyebrow">Customer directory</span>{#if contacts.length}<ul class="crm-list">{#each contacts as contact}<li><strong>{contact.name}</strong><span>{contact.company || contact.email}</span><span class="crm-pill">{contact.status}</span></li>{/each}</ul>{/if}{#if !contacts.length}<div class="crm-empty"><h2>No contacts yet</h2><p>Your first customer relationship will appear here.</p></div>{/if}</section></section></main> }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
page Dashboard {
|
||||||
|
state user = ctx.user
|
||||||
|
state dashboardMetrics = ctx.metrics
|
||||||
|
apis { dashboard GET /api/dashboard { response { return data.metrics } } }
|
||||||
|
load server metrics { return await api.dashboard() }
|
||||||
|
seo { title = "Dashboard" }
|
||||||
|
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard" aria-current="page">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav><form class="crm-sidebar-footer" method="post" action="/api/auth/logout" data-schema="auth-empty"><button class="crm-button crm-button--ghost" type="submit">Log out</button></form></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Good to see you, {user.displayName || user.username}</h1><p>Live figures from your private SQLite workspace.</p></div><a class="crm-button" href="/contacts">View contacts</a></header><div class="crm-stat-grid"><article class="crm-stat"><span>Pipeline value</span><strong>{dashboardMetrics.pipelineValue}</strong></article><article class="crm-stat"><span>Open opportunities</span><strong>{dashboardMetrics.openOpportunities}</strong></article><article class="crm-stat"><span>Total contacts</span><strong>{dashboardMetrics.contacts}</strong></article></div><section class="crm-panel"><span class="crm-eyebrow">Server rendered</span><h2>One secure data path</h2><p class="crm-hero-copy">This page was rendered from authenticated API results on the server. No browser prefetch or client data request is needed to show these metrics.</p></section></section></main> }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
page Deals {
|
||||||
|
apis { listDeals GET /api/deals { response { return data.deals } } }
|
||||||
|
load server deals { return await api.listDeals() }
|
||||||
|
seo { title = "Deals" }
|
||||||
|
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals" aria-current="page">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Sales pipeline</h1><p>Focus on the opportunities most likely to move.</p></div><a class="crm-button" href="/contacts">New deal</a></header><section class="crm-panel"><span class="crm-eyebrow">Open opportunities</span>{#if deals.length}<ul class="crm-list">{#each deals as deal}<li><strong>{deal.title}</strong><span>{deal.contact_name || "Unassigned contact"}</span><span class="crm-pill">{deal.stage}</span></li>{/each}</ul>{/if}{#if !deals.length}<div class="crm-empty"><h2>No open deals</h2><p>Create a contact and turn the relationship into an opportunity.</p></div>{/if}</section></section></main> }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
page Forbidden {
|
||||||
|
seo { title = "Access denied" }
|
||||||
|
view {
|
||||||
|
<main class="crm-status-page"><section class="crm-status-card"><span class="crm-status-icon" aria-hidden="true">!</span><span class="crm-eyebrow">Permission required</span><h1>You do not have access to administration.</h1><p>Your account is working correctly, but an administrator role is required for this page.</p><div class="crm-actions"><a class="crm-button" href="/dashboard">Back to dashboard</a><a class="crm-button crm-button--ghost" href="/contacts">View contacts</a></div></section></main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
page Home {
|
||||||
|
seo { title = "CRM that keeps sales moving" }
|
||||||
|
view {
|
||||||
|
<main
|
||||||
|
class="crm-public"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
class="crm-nav"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="crm-brand"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="crm-brand-mark"
|
||||||
|
>
|
||||||
|
N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/pricing">Pricing</a><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div>
|
||||||
|
</nav>
|
||||||
|
<section
|
||||||
|
class="crm-hero"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="crm-eyebrow"
|
||||||
|
>
|
||||||
|
Customer intelligence, simplified</span><h1>Know every customer. Close every opportunity.</h1><p class="crm-hero-copy">Bring contacts, conversations, and pipeline into one calm workspace built for teams that value clarity.</p><div class="crm-actions"><a class="crm-button" href="/signup">Create your workspace</a><a class="crm-button crm-button--ghost" href="/login">View the CRM</a></div></div><div class="crm-preview"><div class="crm-preview-bar"><span></span><span></span><span></span></div><div class="crm-preview-grid"><article class="crm-preview-card"><small>Pipeline value</small><strong>$125k</strong><small>+18% this month</small></article><article class="crm-preview-card"><small>Active contacts</small><strong>248</strong><small>32 need follow-up</small></article><article class="crm-preview-card"><small>Win rate</small><strong>42%</strong><small>Across qualified deals</small></article><article class="crm-preview-card"><small>Next action</small><strong>8 today</strong><small>Stay ahead of every promise</small></article></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
|
||||||
|
|
||||||
|
page Login {
|
||||||
|
seo { title = "Log in" }
|
||||||
|
view {
|
||||||
|
<AuthSplitLayout brand="Northstar CRM" eyebrow="Customer intelligence" title="Turn every conversation into momentum." description="Sign in to a focused workspace for contacts, pipeline, and activity." features='[{"label":"One customer timeline","description":"Every relationship and opportunity in context."},{"label":"Secure by default","description":"SQL sessions and role-based authorization."},{"label":"Built for focus","description":"A calm workspace without sales-tool clutter."}]'>
|
||||||
|
<div data-slot="form"><SignIn action="/api/auth/login" returnTo="/dashboard" signUpHref="/signup" forgotHref="/login" showPasskey="false" class="border-0 shadow-none" /></div>
|
||||||
|
</AuthSplitLayout>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
page Pricing {
|
||||||
|
seo { title = "Pricing" }
|
||||||
|
view { <main class="crm-public"><nav class="crm-nav"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div></nav><section class="crm-hero"><div><span class="crm-eyebrow">Simple pricing</span><h1>Start free. Scale when your team does.</h1><p class="crm-hero-copy">Everything needed to evaluate a secure, full-stack CRM locally. Move to Team when you are ready to collaborate.</p></div><div class="crm-preview"><article class="crm-preview-card"><small>Starter</small><strong>$0</strong><p>SQLite workspace, CRM flows, authentication and permissions.</p><a class="crm-button" href="/signup">Start building</a></article><article class="crm-preview-card"><small>Team</small><strong>$29</strong><p>Per user/month with shared pipeline and administration.</p></article></div></section></main> }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
|
||||||
|
|
||||||
|
page Signup {
|
||||||
|
seo { title = "Create account" }
|
||||||
|
view {
|
||||||
|
<AuthSplitLayout brand="Northstar CRM" eyebrow="Start in minutes" title="Build relationships, not spreadsheets." description="Create your secure sales workspace and begin with a complete customer view." features='[{"label":"Free local workspace","description":"Explore the complete CRM flow with SQLite."},{"label":"Flexible permissions","description":"Viewer, sales, manager, and admin roles."},{"label":"Your data stays yours","description":"Portable SQL data and explicit migrations."}]'>
|
||||||
|
<div data-slot="form"><SignUp redirect="/dashboard" signInHref="/login" showPhone="false" showUsername="false" requireConsent="false" class="border-0 shadow-none" /></div>
|
||||||
|
</AuthSplitLayout>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// AUTO-GENERATED by `wrnexus dev` - do not edit.
|
||||||
|
// Typed routes support required, optional, and catch-all parameters.
|
||||||
|
|
||||||
|
export interface Routes {
|
||||||
|
"/": Record<string, never>;
|
||||||
|
"/admin": Record<string, never>;
|
||||||
|
"/contacts": Record<string, never>;
|
||||||
|
"/dashboard": Record<string, never>;
|
||||||
|
"/deals": Record<string, never>;
|
||||||
|
"/forbidden": Record<string, never>;
|
||||||
|
"/login": Record<string, never>;
|
||||||
|
"/pricing": Record<string, never>;
|
||||||
|
"/signup": Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteNames {
|
||||||
|
"index": "/";
|
||||||
|
"admin": "/admin";
|
||||||
|
"contacts": "/contacts";
|
||||||
|
"dashboard": "/dashboard";
|
||||||
|
"deals": "/deals";
|
||||||
|
"forbidden": "/forbidden";
|
||||||
|
"login": "/login";
|
||||||
|
"pricing": "/pricing";
|
||||||
|
"signup": "/signup";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteQueries {
|
||||||
|
[path: string]: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoutePath = keyof Routes;
|
||||||
|
export type RouteName = keyof RouteNames;
|
||||||
|
export type RouteQuery<P extends RoutePath> = P extends keyof RouteQueries
|
||||||
|
? RouteQueries[P]
|
||||||
|
: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
type RouteValue = string | readonly string[] | undefined;
|
||||||
|
|
||||||
|
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||||
|
if (value === undefined) return "";
|
||||||
|
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||||
|
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHref(path: string, params: Record<string, RouteValue> = {}): string {
|
||||||
|
const output: string[] = [];
|
||||||
|
for (const segment of path.split("/").filter(Boolean)) {
|
||||||
|
let name: string | undefined;
|
||||||
|
let optional = false;
|
||||||
|
let catchAll = false;
|
||||||
|
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||||
|
optional = true;
|
||||||
|
name = segment.slice(2, -2);
|
||||||
|
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||||
|
name = segment.slice(1, -1);
|
||||||
|
if (name.endsWith("?")) {
|
||||||
|
optional = true;
|
||||||
|
name = name.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
output.push(segment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.startsWith("...")) {
|
||||||
|
catchAll = true;
|
||||||
|
name = name.slice(3);
|
||||||
|
}
|
||||||
|
const value = params[name];
|
||||||
|
if (value === undefined && optional) continue;
|
||||||
|
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
|
||||||
|
output.push(encodeRouteValue(value, catchAll));
|
||||||
|
}
|
||||||
|
return "/" + output.filter(Boolean).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function href<P extends RoutePath>(
|
||||||
|
path: P,
|
||||||
|
...args: keyof Routes[P] extends never
|
||||||
|
? []
|
||||||
|
: Record<string, never> extends Routes[P]
|
||||||
|
? [params?: Routes[P]]
|
||||||
|
: [params: Routes[P]]
|
||||||
|
): string {
|
||||||
|
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||||
|
return buildHref(String(path), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function route<N extends RouteName>(
|
||||||
|
name: N,
|
||||||
|
...args: keyof Routes[RouteNames[N]] extends never
|
||||||
|
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||||
|
: Record<string, never> extends Routes[RouteNames[N]]
|
||||||
|
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||||
|
: [params: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||||
|
): string {
|
||||||
|
const paths: Record<RouteName, RoutePath> = {
|
||||||
|
"index": "/",
|
||||||
|
"admin": "/admin",
|
||||||
|
"contacts": "/contacts",
|
||||||
|
"dashboard": "/dashboard",
|
||||||
|
"deals": "/deals",
|
||||||
|
"forbidden": "/forbidden",
|
||||||
|
"login": "/login",
|
||||||
|
"pricing": "/pricing",
|
||||||
|
"signup": "/signup"
|
||||||
|
} as Record<RouteName, RoutePath>;
|
||||||
|
const output = buildHref(paths[name], (args[0] ?? {}) as Record<string, RouteValue>);
|
||||||
|
const query = args[1];
|
||||||
|
if (!query) return output;
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query))
|
||||||
|
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||||
|
const text = search.toString();
|
||||||
|
return text ? `${output}?${text}` : output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@iconify/tailwind4";
|
||||||
|
@source "../**/*.wrn";
|
||||||
|
@source "../../../packages/auth/components/*.wrn";
|
||||||
|
@source "../../../packages/ui/components/*.wrn";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--wrn-color-bg);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
color: var(--wrn-color-text);
|
||||||
|
background: var(--wrn-color-bg);
|
||||||
|
font-family: Inter, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-public {
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 80% 10%,
|
||||||
|
color-mix(in srgb, var(--wrn-color-primary) 15%, transparent),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
var(--wrn-color-bg);
|
||||||
|
}
|
||||||
|
.crm-nav {
|
||||||
|
width: min(1180px, calc(100% - 2rem));
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
}
|
||||||
|
.crm-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
.crm-brand-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--wrn-color-primary), #7c3aed);
|
||||||
|
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 28%, transparent);
|
||||||
|
}
|
||||||
|
.crm-nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.crm-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 1.1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
background: var(--wrn-color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 22%, transparent);
|
||||||
|
}
|
||||||
|
.crm-button--ghost {
|
||||||
|
color: var(--wrn-color-text);
|
||||||
|
background: var(--wrn-color-surface);
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.crm-hero {
|
||||||
|
width: min(1180px, calc(100% - 2rem));
|
||||||
|
margin: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05fr 0.95fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rem;
|
||||||
|
padding: clamp(4rem, 9vw, 8rem) 0;
|
||||||
|
}
|
||||||
|
.crm-eyebrow {
|
||||||
|
color: var(--wrn-color-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.crm-hero h1 {
|
||||||
|
max-width: 12ch;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: clamp(3rem, 7vw, 5.5rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
}
|
||||||
|
.crm-hero-copy {
|
||||||
|
max-width: 38rem;
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.crm-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.crm-preview {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: color-mix(in srgb, var(--wrn-color-surface) 88%, transparent);
|
||||||
|
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.15);
|
||||||
|
transform: rotate(1.5deg);
|
||||||
|
}
|
||||||
|
.crm-preview-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
.crm-preview-bar span {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wrn-color-border);
|
||||||
|
}
|
||||||
|
.crm-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.crm-preview-card {
|
||||||
|
min-height: 8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--wrn-color-surface-2);
|
||||||
|
}
|
||||||
|
.crm-preview-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.crm-preview-card small {
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16rem 1fr;
|
||||||
|
background: var(--wrn-color-bg);
|
||||||
|
}
|
||||||
|
.crm-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.35rem;
|
||||||
|
border-right: 1px solid var(--wrn-color-border);
|
||||||
|
background: var(--wrn-color-surface);
|
||||||
|
}
|
||||||
|
.crm-menu {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.crm-menu a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.crm-menu a:hover,
|
||||||
|
.crm-menu a[aria-current="page"] {
|
||||||
|
color: var(--wrn-color-primary);
|
||||||
|
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
.crm-sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.crm-content {
|
||||||
|
min-width: 0;
|
||||||
|
padding: clamp(1.25rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
.crm-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.crm-topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.4rem);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.crm-topbar p {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
}
|
||||||
|
.crm-panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--wrn-color-surface);
|
||||||
|
box-shadow: var(--wrn-shadow-1);
|
||||||
|
}
|
||||||
|
.crm-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.crm-stat {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--wrn-color-surface);
|
||||||
|
}
|
||||||
|
.crm-stat span {
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.crm-stat strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.crm-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.crm-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 0.7fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: var(--wrn-color-surface-2);
|
||||||
|
}
|
||||||
|
.crm-pill {
|
||||||
|
justify-self: end;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--wrn-color-primary);
|
||||||
|
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.crm-empty {
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
}
|
||||||
|
.crm-empty h2 {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--wrn-color-text);
|
||||||
|
}
|
||||||
|
.crm-status-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 50% 0%,
|
||||||
|
color-mix(in srgb, var(--wrn-color-primary) 12%, transparent),
|
||||||
|
transparent 45%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.crm-status-card {
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
padding: clamp(1.5rem, 5vw, 3rem);
|
||||||
|
border: 1px solid var(--wrn-color-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--wrn-color-surface);
|
||||||
|
box-shadow: var(--wrn-shadow-2);
|
||||||
|
}
|
||||||
|
.crm-status-card h1 {
|
||||||
|
margin: 0.9rem 0 0.7rem;
|
||||||
|
font-size: clamp(1.8rem, 5vw, 2.5rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
.crm-status-card > p {
|
||||||
|
color: var(--wrn-color-muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.crm-status-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: #b45309;
|
||||||
|
background: #fef3c7;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.crm-nav-links > a:not(.crm-button) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.crm-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
.crm-preview {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.crm-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.crm-sidebar {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.crm-menu {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.crm-sidebar-footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.crm-stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.crm-hero h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
.crm-menu {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.crm-list li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.crm-pill {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||||
|
declare namespace WRNexusGenerated {
|
||||||
|
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||||
|
? { input: I; output: O }
|
||||||
|
: T extends (...args: infer A) => infer R
|
||||||
|
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||||
|
: { input: unknown; output: unknown };
|
||||||
|
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||||
|
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||||
|
? { args: A; result: Awaited<R> }
|
||||||
|
: T extends (db: any, ...rest: any[]) => infer R
|
||||||
|
? { args: Record<string, never>; result: Awaited<R> }
|
||||||
|
: never;
|
||||||
|
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 RouteName = "admin" | "contacts" | "dashboard" | "deals" | "forbidden" | "index" | "login" | "pricing" | "signup";
|
||||||
|
type ApiRoute = "/api/contacts" | "/api/dashboard" | "/api/deals";
|
||||||
|
type RealtimeRoute = never;
|
||||||
|
type EnvironmentKey = never;
|
||||||
|
type TranslationKey = never;
|
||||||
|
type QueueName = never;
|
||||||
|
type CacheKey = never;
|
||||||
|
type Components = Record<string, never>;
|
||||||
|
interface ApiContracts {
|
||||||
|
"/api/dashboard": { GET: ApiContract<typeof import("../api/dashboard.ts")["GET"]> };
|
||||||
|
"/api/contacts": { GET: ApiContract<typeof import("../api/contacts.ts")["GET"]>; POST: ApiContract<typeof import("../api/contacts.ts")["POST"]> };
|
||||||
|
"/api/deals": { GET: ApiContract<typeof import("../api/deals.ts")["GET"]> };
|
||||||
|
}
|
||||||
|
interface MiddlewareContexts {
|
||||||
|
"authz": MiddlewareContext<(typeof import("../middleware/authz.ts"))["default"]>;
|
||||||
|
"protected": MiddlewareContext<(typeof import("../middleware/protected.ts"))["default"]>;
|
||||||
|
}
|
||||||
|
type DatabaseQueries = Record<string, never>;
|
||||||
|
type RealtimeMessages = Record<string, never>;
|
||||||
|
type QueuePayloads = Record<string, never>;
|
||||||
|
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"];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// AUTO-GENERATED plugin type aggregation - do not edit.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "wrnexus-crm-example",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run ../../packages/cli/src/index.ts dev .",
|
||||||
|
"build": "bun run ../../packages/cli/src/index.ts build .",
|
||||||
|
"db:migrate": "bun run ../../packages/cli/src/index.ts db migrate .",
|
||||||
|
"db:seed": "bun run ../../packages/cli/src/index.ts db seed .",
|
||||||
|
"test": "bun test test",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"check": "bun run typecheck && bun run test && bun run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/auth": "workspace:*",
|
||||||
|
"@wrnexus/authz": "workspace:*",
|
||||||
|
"@wrnexus/core": "workspace:*",
|
||||||
|
"@wrnexus/db": "workspace:*",
|
||||||
|
"@wrnexus/styles": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDb, loadMigrations, migrate } from "@wrnexus/db";
|
||||||
|
import { sqlite } from "@wrnexus/db/sqlite";
|
||||||
|
import seed from "../app/db/seed.ts";
|
||||||
|
|
||||||
|
const root = join(import.meta.dir, "..");
|
||||||
|
const source = (path: string) => readFileSync(join(root, path), "utf8");
|
||||||
|
|
||||||
|
test("the CRM migration applies to SQLite, is idempotent, and the seed is repeatable", async () => {
|
||||||
|
const db = createDb(sqlite());
|
||||||
|
const migrations = join(root, "app", "db", "migrations");
|
||||||
|
|
||||||
|
expect(loadMigrations(migrations).map(({ name }) => name)).toEqual(["001_crm"]);
|
||||||
|
expect(await migrate(db, migrations)).toEqual(["001_crm"]);
|
||||||
|
expect(await migrate(db, migrations)).toEqual([]);
|
||||||
|
await seed(db);
|
||||||
|
await seed(db);
|
||||||
|
|
||||||
|
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_contacts")).toEqual({
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_deals")).toEqual({
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.all(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('_wrn_authz_assignment','_wrn_authz_grant') ORDER BY name",
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authentication uses SQL, plugin migrations, signup/login components, and protected routes", () => {
|
||||||
|
expect(source("app/lib/auth.ts")).toContain("SqlAuthStore");
|
||||||
|
expect(source("wrnexus.config.ts")).toContain("migrations: true");
|
||||||
|
expect(source("app/pages/login.wrn")).toContain("<SignIn");
|
||||||
|
expect(source("app/pages/signup.wrn")).toContain("<SignUp");
|
||||||
|
expect(source("app/middleware/protected.ts")).toContain('requireAuth({ loginPath: "/login" })');
|
||||||
|
expect(source("app/pages/dashboard.wrn")).toContain("/api/auth/logout");
|
||||||
|
expect(source("app/pages/login.wrn")).toContain("AuthSplitLayout");
|
||||||
|
expect(source("app/pages/login.wrn")).toContain('data-slot="form"');
|
||||||
|
expect(source("app/pages/signup.wrn")).not.toContain("Already registered?");
|
||||||
|
expect(source("wrnexus.config.ts")).toContain('entry: "app/styles/global.css"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authorization is database-backed and guards both APIs and administration", () => {
|
||||||
|
expect(source("app/middleware/authz.ts")).toContain("dbPermissionStore(getDb())");
|
||||||
|
expect(source("app/lib/auth.ts")).toContain('assignRole(user.id, "sales-rep")');
|
||||||
|
expect(source("app/api/contacts.ts")).toContain('can(ctx, "contact:write")');
|
||||||
|
expect(source("app/api/deals.ts")).toContain('can(ctx, "deal:read")');
|
||||||
|
expect(source("app/middleware/protected.ts")).toContain('can(ctx, "admin:access")');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the app includes public, authentication, and protected CRM pages", () => {
|
||||||
|
for (const page of [
|
||||||
|
"index",
|
||||||
|
"pricing",
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
"dashboard",
|
||||||
|
"contacts",
|
||||||
|
"deals",
|
||||||
|
"admin",
|
||||||
|
"forbidden",
|
||||||
|
]) {
|
||||||
|
expect(source(`app/pages/${page}.wrn`)).toContain("page ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("protected CRM data is loaded on the server without browser API bindings", () => {
|
||||||
|
for (const page of ["contacts", "deals", "dashboard"]) {
|
||||||
|
const contents = source(`app/pages/${page}.wrn`);
|
||||||
|
expect(contents).toContain("load server");
|
||||||
|
expect(contents).not.toContain(' api="');
|
||||||
|
}
|
||||||
|
expect(source("app/api/dashboard.ts")).toContain("pipeline_value_cents");
|
||||||
|
expect(source("app/lib/workspace.ts")).toContain("INSERT OR IGNORE INTO crm_contacts");
|
||||||
|
expect(source("app/lib/auth.ts")).toContain("ensureCrmWorkspace(user.id)");
|
||||||
|
expect(source("app/middleware/protected.ts")).toContain('new URL("/forbidden", ctx.url)');
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": { "noEmit": true },
|
||||||
|
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import type { AuthConfig } from "@wrnexus/auth";
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
import { auth } from "./app/lib/auth.ts";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
seo: {
|
||||||
|
title: "Northstar CRM",
|
||||||
|
titleTemplate: "%s | Northstar CRM",
|
||||||
|
description: "A complete WrNexus SQLite CRM example.",
|
||||||
|
canonicalBase: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
theme: { palette: "blue", default: "light" },
|
||||||
|
db: { driver: "sqlite", url: "file:./crm.sqlite" },
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
async process({ entryPath, appRoot, mode }) {
|
||||||
|
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||||
|
if (mode === "production") args.push("--minify");
|
||||||
|
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||||
|
},
|
||||||
|
failureMode: "throw",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
engine: auth,
|
||||||
|
routes: true,
|
||||||
|
middleware: true,
|
||||||
|
components: true,
|
||||||
|
migrations: true,
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
test: { db: { driver: "sqlite", url: `file:${join(import.meta.dir, ".tmp-test.sqlite")}` } },
|
||||||
|
},
|
||||||
|
security: { cors: { enabled: false } },
|
||||||
|
} satisfies AppConfig & { auth: AuthConfig };
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
|||||||
import type { AppConfig } from "@wrnexus/styles";
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
compatibilityDate: "2026-08-02",
|
|
||||||
frameworkBehaviour: 1,
|
|
||||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||||
// automatic progressive navigation. Package plugins are discovered from the
|
// automatic progressive navigation. Package plugins are discovered from the
|
||||||
// installed packages above; add custom plugins to this array when needed.
|
// installed packages above; add custom plugins to this array when needed.
|
||||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
|||||||
checkComponentProps: true,
|
checkComponentProps: true,
|
||||||
generateDeclarations: true,
|
generateDeclarations: true,
|
||||||
},
|
},
|
||||||
functions: { legacyDefaultRuntime: "current" },
|
|
||||||
stores: { strictMutations: true, persistence: true },
|
stores: { strictMutations: true, persistence: true },
|
||||||
compatibility: {
|
|
||||||
legacyEmit: false,
|
|
||||||
legacyEventProps: false,
|
|
||||||
legacyComponentDiscovery: false,
|
|
||||||
stringLayouts: false,
|
|
||||||
},
|
|
||||||
experimental: {},
|
experimental: {},
|
||||||
|
|
||||||
performance: {
|
performance: {
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
|||||||
import type { AppConfig } from "@wrnexus/styles";
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
compatibilityDate: "2026-08-02",
|
|
||||||
frameworkBehaviour: 1,
|
|
||||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||||
// automatic progressive navigation. Package plugins are discovered from the
|
// automatic progressive navigation. Package plugins are discovered from the
|
||||||
// installed packages above; add custom plugins to this array when needed.
|
// installed packages above; add custom plugins to this array when needed.
|
||||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
|||||||
checkComponentProps: true,
|
checkComponentProps: true,
|
||||||
generateDeclarations: true,
|
generateDeclarations: true,
|
||||||
},
|
},
|
||||||
functions: { legacyDefaultRuntime: "current" },
|
|
||||||
stores: { strictMutations: true, persistence: true },
|
stores: { strictMutations: true, persistence: true },
|
||||||
compatibility: {
|
|
||||||
legacyEmit: false,
|
|
||||||
legacyEventProps: false,
|
|
||||||
legacyComponentDiscovery: false,
|
|
||||||
stringLayouts: false,
|
|
||||||
},
|
|
||||||
experimental: {},
|
experimental: {},
|
||||||
|
|
||||||
performance: {
|
performance: {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/pubsub": "0.8.6"
|
"@wrnexus/pubsub": "^0.8.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ component SignIn {
|
|||||||
class = ""
|
class = ""
|
||||||
}
|
}
|
||||||
view {
|
view {
|
||||||
<section {...attrs} data-wrnexus-runtime="auth" data-auth-sign-in data-mfa-href='{mfaHref}' class='w-full max-w-md rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'>
|
<section {...attrs} data-wrnexus-runtime="auth" data-auth-sign-in data-mfa-href='{mfaHref}' class='wrn-auth-sign-in w-full max-w-md rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'>
|
||||||
<header class="mb-6 space-y-1.5">
|
<header class="wrn-auth-sign-in__header mb-6 space-y-1.5">
|
||||||
<h1 class="m-0 text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 class="m-0 text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
<p class="m-0 text-sm text-[var(--wrn-color-muted)]">{description}</p>
|
<p class="m-0 text-sm text-[var(--wrn-color-muted)]">{description}</p>
|
||||||
</header>
|
</header>
|
||||||
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="space-y-4">
|
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="wrn-auth-sign-in__form">
|
||||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||||
<input type="hidden" name="deviceFingerprint" value="" />
|
<input type="hidden" name="deviceFingerprint" value="" />
|
||||||
<input type="hidden" name="deviceName" value="" />
|
<input type="hidden" name="deviceName" value="" />
|
||||||
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
||||||
<div class="space-y-1.5">
|
<div>
|
||||||
<TogglePassword
|
<TogglePassword
|
||||||
label="{passwordLabel}"
|
label="{passwordLabel}"
|
||||||
cornerHint="Forgot password?"
|
cornerHint="Forgot password?"
|
||||||
@@ -48,9 +48,7 @@ component SignIn {
|
|||||||
size="{size}"
|
size="{size}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if showRemember}
|
{#if showRemember}<div class="wrn-auth-sign-in__remember"><Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" /></div>{/if}
|
||||||
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
|
|
||||||
{/if}
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
|
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
|
||||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||||
@@ -64,4 +62,18 @@ component SignIn {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
.wrn-auth-sign-in__header { margin-bottom: 1.5rem; }
|
||||||
|
.wrn-auth-sign-in__header h1 { font-size: 1.75rem; line-height: 1.15; }
|
||||||
|
.wrn-auth-sign-in__header p { margin-top: 0.35rem; font-size: 0.9375rem; line-height: 1.5; }
|
||||||
|
.wrn-auth-sign-in__form { display: grid; gap: 1.15rem; }
|
||||||
|
.wrn-auth-sign-in__form > * { margin: 0 !important; }
|
||||||
|
.wrn-auth-sign-in__remember { margin-top: -0.25rem !important; }
|
||||||
|
.wrn-auth-sign-in__form .wrn-next__field-heading label,
|
||||||
|
.wrn-auth-sign-in__form .wrn-next__password-heading { font-size: 0.875rem; font-weight: 650; }
|
||||||
|
.wrn-auth-sign-in__form .wrn-next__field-control > input { min-height: 3rem; font-size: 0.875rem; }
|
||||||
|
.wrn-auth-sign-in__form [data-error]:empty { display: none; }
|
||||||
|
.wrn-auth-sign-in__form .wrn-action[data-full-width="true"] { margin-top: 0.15rem !important; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ component SignUp {
|
|||||||
<section
|
<section
|
||||||
{...attrs}
|
{...attrs}
|
||||||
data-wrnexus-runtime="auth"
|
data-wrnexus-runtime="auth"
|
||||||
class='w-full max-w-lg rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'
|
class='wrn-auth-sign-up w-full max-w-lg rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
class="mb-6 space-y-1.5"
|
class="mb-6 space-y-1.5"
|
||||||
@@ -50,7 +50,7 @@ component SignUp {
|
|||||||
data-schema='{schema}'
|
data-schema='{schema}'
|
||||||
data-redirect='{redirect}'
|
data-redirect='{redirect}'
|
||||||
novalidate
|
novalidate
|
||||||
class="grid gap-4 sm:grid-cols-2"
|
class="wrn-auth-sign-up__form"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="auth-sign-up-name"
|
id="auth-sign-up-name"
|
||||||
@@ -61,7 +61,6 @@ component SignUp {
|
|||||||
icon="icon-[lucide--user]"
|
icon="icon-[lucide--user]"
|
||||||
color="{color}"
|
color="{color}"
|
||||||
size="{size}"
|
size="{size}"
|
||||||
class="sm:col-span-2"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -100,7 +99,6 @@ component SignUp {
|
|||||||
icon="icon-[lucide--circle-user-round]"
|
icon="icon-[lucide--circle-user-round]"
|
||||||
color="{color}"
|
color="{color}"
|
||||||
size="{size}"
|
size="{size}"
|
||||||
class="sm:col-span-2"
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -176,15 +174,7 @@ component SignUp {
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<div class="wrn-auth-sign-up__submit"><Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" /></div>
|
||||||
type="submit"
|
|
||||||
label="{submitLabel}"
|
|
||||||
variant="solid"
|
|
||||||
color="{color}"
|
|
||||||
size="{size}"
|
|
||||||
fullWidth="true"
|
|
||||||
class="wrn-auth-sign-up__wide sm:col-span-2"
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -196,9 +186,19 @@ component SignUp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
style {
|
style {
|
||||||
|
.wrn-auth-sign-up > header { margin-bottom: 1.5rem; }
|
||||||
|
.wrn-auth-sign-up > header h1 { font-size: 1.75rem; line-height: 1.15; letter-spacing: -0.025em; }
|
||||||
|
.wrn-auth-sign-up > header p { margin-top: 0.35rem; font-size: 0.9375rem; line-height: 1.5; }
|
||||||
|
.wrn-auth-sign-up__form { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.15rem; }
|
||||||
|
.wrn-auth-sign-up__form .wrn-next__field-heading label,
|
||||||
|
.wrn-auth-sign-up__form .wrn-next--strong-password > label { font-size: 0.875rem; font-weight: 650; }
|
||||||
|
.wrn-auth-sign-up__form .wrn-next__field-control > input { min-height: 3rem; font-size: 0.875rem; }
|
||||||
|
.wrn-auth-sign-up__form [data-error]:empty { display: none; }
|
||||||
.wrn-auth-sign-up__wide {
|
.wrn-auth-sign-up__wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.wrn-auth-sign-up__submit { width: 100%; margin-top: 0.25rem; }
|
||||||
|
.wrn-auth-sign-up__submit .wrn-btn { min-height: 3rem; font-size: 0.9375rem; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.11",
|
"version": "0.8.13",
|
||||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|||||||
@@ -160,10 +160,6 @@ export interface AuthEngine {
|
|||||||
key: string;
|
key: string;
|
||||||
response: unknown;
|
response: unknown;
|
||||||
name?: string;
|
name?: string;
|
||||||
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
|
|
||||||
rpId?: string;
|
|
||||||
/** @deprecated Verification uses the origin bound to the issued challenge. */
|
|
||||||
origin?: string;
|
|
||||||
},
|
},
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
beginPasskeyAuthentication(input: {
|
beginPasskeyAuthentication(input: {
|
||||||
@@ -176,10 +172,6 @@ export interface AuthEngine {
|
|||||||
response: unknown;
|
response: unknown;
|
||||||
/** Request metadata used only for the resulting session. */
|
/** Request metadata used only for the resulting session. */
|
||||||
session?: Partial<AuthSession>;
|
session?: Partial<AuthSession>;
|
||||||
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
|
|
||||||
rpId?: string;
|
|
||||||
/** @deprecated Verification uses the origin bound to the issued challenge. */
|
|
||||||
origin?: string;
|
|
||||||
}): Promise<AuthResult>;
|
}): Promise<AuthResult>;
|
||||||
changePassword(
|
changePassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -832,7 +824,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
store,
|
store,
|
||||||
onSignedIn: options.onSignedIn,
|
onSignedIn: options.onSignedIn,
|
||||||
onSignedOut: options.onSignedOut,
|
onSignedOut: options.onSignedOut,
|
||||||
onSuccessfulSignUp: options.onSuccessfulSignUp ?? options.onSuccessfullSignUp,
|
onSuccessfulSignUp: options.onSuccessfulSignUp,
|
||||||
|
|
||||||
async register(input) {
|
async register(input) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
getAuthUser,
|
getAuthUser,
|
||||||
} from "../middleware.ts";
|
} from "../middleware.ts";
|
||||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
||||||
import type {
|
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||||
AuthSessionVerificationHandler,
|
|
||||||
AuthSignedInHandler,
|
|
||||||
AuthSignedOutHandler,
|
|
||||||
} from "../types.ts";
|
|
||||||
|
|
||||||
function text(value: unknown): string {
|
function text(value: unknown): string {
|
||||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||||
@@ -53,18 +49,14 @@ export interface AuthHttpOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
schemas?: AuthSchemaOverrides | AuthSchemaSet;
|
schemas?: AuthSchemaOverrides | AuthSchemaSet;
|
||||||
passkey?: AuthPasskeyHttpOptions;
|
passkey?: AuthPasskeyHttpOptions;
|
||||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
|
||||||
onSignedIn?: AuthSignedInHandler;
|
|
||||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
|
||||||
onSignedOut?: AuthSignedOutHandler;
|
|
||||||
onSessionVerification?: AuthSessionVerificationHandler;
|
onSessionVerification?: AuthSessionVerificationHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||||
const engine = options.engine;
|
const engine = options.engine;
|
||||||
const schemas = resolveAuthSchemas(options.schemas);
|
const schemas = resolveAuthSchemas(options.schemas);
|
||||||
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
|
const onSignedIn = engine.onSignedIn;
|
||||||
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
|
const onSignedOut = engine.onSignedOut;
|
||||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||||
|
|
||||||
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
|
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||||
import type { AuthEngine } from "./engine.ts";
|
import type { AuthEngine } from "./engine.ts";
|
||||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||||
import type {
|
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||||
AuthSessionVerificationHandler,
|
|
||||||
AuthSignedInHandler,
|
|
||||||
AuthSignedOutHandler,
|
|
||||||
} from "./types.ts";
|
|
||||||
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
||||||
import {
|
import {
|
||||||
clearDefaultAuthEngine,
|
clearDefaultAuthEngine,
|
||||||
@@ -52,10 +48,6 @@ export interface AuthConfig {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
csrf?: boolean;
|
csrf?: boolean;
|
||||||
passkey?: AuthPasskeyHttpOptions;
|
passkey?: AuthPasskeyHttpOptions;
|
||||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
|
||||||
onSignedIn?: AuthSignedInHandler;
|
|
||||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
|
||||||
onSignedOut?: AuthSignedOutHandler;
|
|
||||||
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
||||||
sessionCookieName?: string;
|
sessionCookieName?: string;
|
||||||
/** Customize the package forward-auth verification response. */
|
/** Customize the package forward-auth verification response. */
|
||||||
@@ -94,8 +86,6 @@ interface ResolvedAuthConfig {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
csrf: boolean;
|
csrf: boolean;
|
||||||
passkey?: AuthPasskeyHttpOptions;
|
passkey?: AuthPasskeyHttpOptions;
|
||||||
onSignedIn?: AuthSignedInHandler;
|
|
||||||
onSignedOut?: AuthSignedOutHandler;
|
|
||||||
sessionCookieName?: string;
|
sessionCookieName?: string;
|
||||||
onSessionVerification?: AuthSessionVerificationHandler;
|
onSessionVerification?: AuthSessionVerificationHandler;
|
||||||
}
|
}
|
||||||
@@ -156,8 +146,6 @@ function resolveConfig(
|
|||||||
baseUrl: raw.baseUrl,
|
baseUrl: raw.baseUrl,
|
||||||
csrf: raw.csrf ?? true,
|
csrf: raw.csrf ?? true,
|
||||||
passkey: raw.passkey,
|
passkey: raw.passkey,
|
||||||
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
|
|
||||||
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
|
|
||||||
sessionCookieName: raw.sessionCookieName,
|
sessionCookieName: raw.sessionCookieName,
|
||||||
onSessionVerification: raw.onSessionVerification,
|
onSessionVerification: raw.onSessionVerification,
|
||||||
};
|
};
|
||||||
@@ -392,10 +380,6 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
|||||||
|
|
||||||
passkey: value.passkey,
|
passkey: value.passkey,
|
||||||
|
|
||||||
onSignedIn: value.onSignedIn,
|
|
||||||
|
|
||||||
onSignedOut: value.onSignedOut,
|
|
||||||
|
|
||||||
sessionCookieName: value.sessionCookieName,
|
sessionCookieName: value.sessionCookieName,
|
||||||
|
|
||||||
onSessionVerification: value.onSessionVerification,
|
onSessionVerification: value.onSessionVerification,
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
|
|||||||
schemas: getDefaultAuthSchemas(),
|
schemas: getDefaultAuthSchemas(),
|
||||||
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
|
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
|
||||||
passkey: routeOptions.passkey,
|
passkey: routeOptions.passkey,
|
||||||
onSignedIn: routeOptions.onSignedIn,
|
|
||||||
onSignedOut: routeOptions.onSignedOut,
|
|
||||||
onSessionVerification: routeOptions.onSessionVerification,
|
onSessionVerification: routeOptions.onSessionVerification,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Context } from "@wrnexus/core";
|
|
||||||
import type { AuthEngine } from "./engine.ts";
|
import type { AuthEngine } from "./engine.ts";
|
||||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||||
@@ -8,10 +7,6 @@ export interface DefaultAuthRouteOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
csrf?: boolean;
|
csrf?: boolean;
|
||||||
passkey?: AuthPasskeyHttpOptions;
|
passkey?: AuthPasskeyHttpOptions;
|
||||||
|
|
||||||
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
|
|
||||||
|
|
||||||
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
|
|
||||||
sessionCookieName?: string;
|
sessionCookieName?: string;
|
||||||
onSessionVerification?: AuthSessionVerificationHandler;
|
onSessionVerification?: AuthSessionVerificationHandler;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,8 +420,6 @@ export interface AuthEngineOptions {
|
|||||||
* redirects to /sign-in.
|
* redirects to /sign-in.
|
||||||
*/
|
*/
|
||||||
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||||
/** @deprecated Misspelled alias; use onSuccessfulSignUp. */
|
|
||||||
onSuccessfullSignUp?: AuthSuccessfulSignUpHandler;
|
|
||||||
breachProvider?: PasswordBreachProvider;
|
breachProvider?: PasswordBreachProvider;
|
||||||
passkeys?: PasskeyProvider;
|
passkeys?: PasskeyProvider;
|
||||||
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user