Compare commits
97
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 |
@@ -25,3 +25,6 @@ tsconfig.focus.json
|
||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||
# which requires the scaffold to live inside the repo tree).
|
||||
**/test/.tmp-*/
|
||||
|
||||
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||
.superpowers/
|
||||
|
||||
@@ -13,6 +13,7 @@ bun.lockb
|
||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
**/*.generated.api-checks.ts
|
||||
|
||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||
editors/vscode/src/compiler.cjs
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 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
|
||||
|
||||
- Added the framework request context to `.wrn` language-server type environments.
|
||||
|
||||
@@ -93,6 +93,25 @@
|
||||
"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": {
|
||||
"name": "i18n-showcase",
|
||||
"version": "0.8.0",
|
||||
@@ -216,7 +235,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
@@ -272,7 +291,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.42",
|
||||
"version": "0.8.47",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -300,7 +319,7 @@
|
||||
},
|
||||
"packages/compiler": {
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.14",
|
||||
"dependencies": {
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
@@ -318,11 +337,11 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.22",
|
||||
"version": "0.8.25",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
@@ -337,7 +356,7 @@
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.38",
|
||||
"version": "0.8.42",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -451,13 +470,14 @@
|
||||
},
|
||||
"packages/language-server": {
|
||||
"name": "@wrnexus/language-server",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.11",
|
||||
"bin": {
|
||||
"wrnexus-language-server": "src/server.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/typecheck": "workspace:*",
|
||||
"vscode-html-languageservice": "^5.6.2",
|
||||
},
|
||||
},
|
||||
"packages/mcp": {
|
||||
@@ -533,7 +553,7 @@
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@wrnexus/react",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"dependencies": {
|
||||
"@wrnexus/store": "workspace:*",
|
||||
},
|
||||
@@ -621,7 +641,7 @@
|
||||
},
|
||||
"packages/syntax": {
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
},
|
||||
"packages/test": {
|
||||
"name": "@wrnexus/test",
|
||||
@@ -641,7 +661,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.21",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
@@ -662,7 +682,7 @@
|
||||
},
|
||||
"packages/validation": {
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.11",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "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=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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"],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
||||
|
||||
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
|
||||
- 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
|
||||
boundaries.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-10
@@ -1040,7 +1040,8 @@
|
||||
"rpcManifest",
|
||||
"runtimeCapabilities",
|
||||
"runtimeTypeOf",
|
||||
"serializeIslandProps"
|
||||
"serializeIslandProps",
|
||||
"stripBrowserTypes"
|
||||
]
|
||||
},
|
||||
"@wrnexus/content": {
|
||||
@@ -1083,6 +1084,7 @@
|
||||
"BackoffStrategy",
|
||||
"Bucket",
|
||||
"BudgetViolation",
|
||||
"BuiltApiRequest",
|
||||
"Bulkhead",
|
||||
"BulkheadOptions",
|
||||
"CSRF_COOKIE",
|
||||
@@ -1203,6 +1205,7 @@
|
||||
"UploadScanner",
|
||||
"assertTenantAccess",
|
||||
"bridgeRealtime",
|
||||
"buildApiRequest",
|
||||
"cacheControl",
|
||||
"checkPerformanceBudgets",
|
||||
"collectUploads",
|
||||
@@ -1228,6 +1231,7 @@
|
||||
"escapeHtml",
|
||||
"etag",
|
||||
"executionContextFromHttp",
|
||||
"getRequestContext",
|
||||
"getUser",
|
||||
"hashPassword",
|
||||
"isRoomDefinition",
|
||||
@@ -1259,9 +1263,11 @@
|
||||
"requestId",
|
||||
"requestLogger",
|
||||
"requireAuth",
|
||||
"requireRequestContext",
|
||||
"requireTenant",
|
||||
"resilientCall",
|
||||
"resolveRequestUrl",
|
||||
"runWithRequestContext",
|
||||
"sanitizeFilename",
|
||||
"saveUpload",
|
||||
"saveUploadSecure",
|
||||
@@ -1464,6 +1470,7 @@
|
||||
"startServer",
|
||||
"toRequest",
|
||||
"validateRpcCsrf",
|
||||
"withServerFnRequestContext",
|
||||
"writeResponse"
|
||||
],
|
||||
"./serve-entry": []
|
||||
@@ -1833,7 +1840,7 @@
|
||||
"Position",
|
||||
"Range",
|
||||
"TextDocument",
|
||||
"WRN_COMPLETIONS",
|
||||
"WRN_KEYWORDS",
|
||||
"WorkspaceCompletionItem",
|
||||
"clearWorkspaceIndexCache",
|
||||
"completionItems",
|
||||
@@ -1854,7 +1861,11 @@
|
||||
"workspaceCompletionItems",
|
||||
"workspaceSymbolLocations"
|
||||
],
|
||||
"./server": []
|
||||
"./server": [
|
||||
"ApiCallCompletionItem",
|
||||
"apiCallCompletions",
|
||||
"apiCallHover"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mcp": {
|
||||
".": [
|
||||
@@ -2632,10 +2643,6 @@
|
||||
"BrowserCookiePreference",
|
||||
"BrowserCookiesConfig",
|
||||
"BuildConfig",
|
||||
"CURRENT_COMPATIBILITY_DATE",
|
||||
"CURRENT_FRAMEWORK_BEHAVIOUR",
|
||||
"CompatibilityPolicy",
|
||||
"CompatibilityReport",
|
||||
"ConfigIssue",
|
||||
"ContrastResult",
|
||||
"CssPerformanceAuditIssue",
|
||||
@@ -2686,7 +2693,6 @@
|
||||
"findStyleEntry",
|
||||
"fontCspSources",
|
||||
"headToString",
|
||||
"isCompatibilityDate",
|
||||
"loadAppConfig",
|
||||
"loadEnv",
|
||||
"loadRawConfig",
|
||||
@@ -2699,7 +2705,6 @@
|
||||
"renderThemeRuntime",
|
||||
"resolveAccentName",
|
||||
"resolveBrowserCookieOptions",
|
||||
"resolveCompatibility",
|
||||
"resolveConfigLayers",
|
||||
"resolveProfile",
|
||||
"resolveThemeConfig",
|
||||
@@ -2787,6 +2792,7 @@
|
||||
"parseStructuredImports",
|
||||
"positionAt",
|
||||
"runtimeTypeOf",
|
||||
"skipLiteralOrComment",
|
||||
"sliceSource",
|
||||
"stripRuntimeFunctionModifiers",
|
||||
"supportsSyntaxFeature",
|
||||
@@ -2853,7 +2859,10 @@
|
||||
"LexError",
|
||||
"Lexer",
|
||||
"Token",
|
||||
"TokenType"
|
||||
"TokenType",
|
||||
"isIdentPart",
|
||||
"isIdentStart",
|
||||
"skipLiteralOrComment"
|
||||
],
|
||||
"./types": [
|
||||
"RuntimeType",
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||
diagnostics in packaged extension environments.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||
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
|
||||
consistently in the repository and extension development environment.
|
||||
|
||||
|
||||
@@ -134,6 +134,11 @@
|
||||
"maximum": 240,
|
||||
"scope": "resource",
|
||||
"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": {
|
||||
"*.wrn": "wrn"
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"wrn": "html"
|
||||
},
|
||||
"[wrn]": {
|
||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||
"editor.formatOnSave": false,
|
||||
@@ -276,13 +284,14 @@
|
||||
"check": "bun run build && bun run test && bun run validate",
|
||||
"vscode:prepublish": "bun run check",
|
||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
||||
"publish": "vsce publish --no-dependencies",
|
||||
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
},
|
||||
"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 };
|
||||
+715
-240
File diff suppressed because it is too large
Load Diff
@@ -306,6 +306,54 @@ const BLOCK_COMPLETIONS = [
|
||||
"}",
|
||||
].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",
|
||||
detail: "Include-only store persistence",
|
||||
@@ -570,6 +618,41 @@ function isInsideWatch(document, position) {
|
||||
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) {
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
|
||||
@@ -634,6 +717,8 @@ function addFunctionCompletions(items, document) {
|
||||
}
|
||||
|
||||
function provideCompletionItems(document, position) {
|
||||
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||
|
||||
const items = [];
|
||||
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
@@ -707,6 +792,7 @@ module.exports = {
|
||||
extractProps,
|
||||
extractRouteParams,
|
||||
extractStates,
|
||||
isInsideViewBlock,
|
||||
provideCompletionItems,
|
||||
registerCompletionProvider,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
||||
// WRN editor extension source hash: d0ca0feee6516ae43e902e6aea0424bc5c3d05790708eb8a8c68c98162eb99bb
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
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
|
||||
var path = require("node:path");
|
||||
var vscode = require("vscode");
|
||||
var { LanguageClient, TransportKind } = require_main5();
|
||||
var { registerAutoCloseTags } = require_auto_close_tags();
|
||||
var WRN_LANGUAGE_ID = "wrn";
|
||||
var client;
|
||||
async function recoverWrnLanguage(document) {
|
||||
@@ -22730,6 +22783,7 @@ async function activate(context) {
|
||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||
await client.start();
|
||||
registerAutoCloseTags(context, client);
|
||||
}
|
||||
async function deactivate() {
|
||||
const running = client;
|
||||
@@ -22737,4 +22791,4 @@ async function deactivate() {
|
||||
if (running)
|
||||
await running.stop();
|
||||
}
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const path = require("node:path");
|
||||
const vscode = require("vscode");
|
||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||
|
||||
const WRN_LANGUAGE_ID = "wrn";
|
||||
/** @type {LanguageClient | undefined} */
|
||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||
);
|
||||
await client.start();
|
||||
registerAutoCloseTags(context, client);
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
||||
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": "#mode-block"
|
||||
"include": "#api-block"
|
||||
},
|
||||
{
|
||||
"include": "#api-block"
|
||||
"include": "#apis-block"
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
@@ -513,8 +513,8 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode-block": {
|
||||
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
|
||||
"apis-block": {
|
||||
"begin": "\\b(apis)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
@@ -534,21 +534,18 @@
|
||||
"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": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.function.wrn"
|
||||
},
|
||||
"3": {
|
||||
"2": {
|
||||
"name": "constant.language.http-method.wrn"
|
||||
},
|
||||
"4": {
|
||||
"3": {
|
||||
"name": "string.unquoted.route.wrn"
|
||||
},
|
||||
"5": {
|
||||
"4": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
@@ -558,8 +555,36 @@
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"begin": "\\b(request|response|error)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
"end": "\\}",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
{
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
@@ -567,9 +592,6 @@
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 { 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
|
||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return {};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({});
|
||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
test("extracts dynamic route params from filename", () => {
|
||||
const document = {
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const Module = require("node:module");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") {
|
||||
return {
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
});
|
||||
const {
|
||||
findTopLevelDeclaration,
|
||||
maskLeadingTrivia,
|
||||
@@ -34,7 +28,7 @@ const {
|
||||
validateLayoutUsage,
|
||||
validateRootMembers,
|
||||
} = require("../src/diagnostics");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
function mockDocument() {
|
||||
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");
|
||||
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) {
|
||||
bad("Marketplace metadata", e.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||
*
|
||||
* These files run under `node --test` (see the package's test script), where
|
||||
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||
* root also picks them up by filename, and Bun resolves `require` through its
|
||||
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||
* module covers that case, leaving one shim that works under both runners.
|
||||
*
|
||||
* Returns a function restoring the original loader.
|
||||
*/
|
||||
function installVsCodeHost(stub) {
|
||||
const Module = require("node:module");
|
||||
|
||||
if (typeof Bun !== "undefined") {
|
||||
require("bun").plugin({
|
||||
name: "vscode-host-stub",
|
||||
setup(build) {
|
||||
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return stub;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Module._load = originalLoad;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installVsCodeHost };
|
||||
@@ -0,0 +1,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)) };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
// SSR data bindings run on the server before the HTML is sent.
|
||||
// The API itself lives in app/api/users/ssr.ts; this block only calls it
|
||||
// and renders the response into HTML.
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
// API calls used by this page. `ssrUsers` is bound in the initial render
|
||||
// (its endpoint lives in app/api/users/ssr.ts); `csrUsers` is bound after
|
||||
// hydration in the browser (app/api/users/csr.ts). Both just call the same
|
||||
// users endpoint shape, so the response handling looks the same.
|
||||
apis {
|
||||
ssrUsers GET /api/users/ssr {
|
||||
response {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(data.users)} - visit ${visits}`
|
||||
}
|
||||
}
|
||||
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(users)} - visit ${visits}`
|
||||
csrUsers GET /api/users/csr {
|
||||
response {
|
||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(data.users)} - ${label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client data bindings hydrate after the first paint. The browser only sees
|
||||
// an opaque data-wrnexus-csr id; WrNexus calls app/api/users/csr.ts on the server.
|
||||
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"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(users)} - ${label}`
|
||||
functions {
|
||||
shared function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/about": Record<string, never>;
|
||||
"/api-block-demo": Record<string, never>;
|
||||
"/async-data": Record<string, never>;
|
||||
"/chat": Record<string, never>;
|
||||
"/client-only": Record<string, never>;
|
||||
@@ -27,6 +28,7 @@ export interface Routes {
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"about": "/about";
|
||||
"api.block.demo": "/api-block-demo";
|
||||
"async.data": "/async-data";
|
||||
"chat": "/chat";
|
||||
"client.only": "/client-only";
|
||||
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"about": "/about",
|
||||
"api.block.demo": "/api-block-demo",
|
||||
"async.data": "/async-data",
|
||||
"chat": "/chat",
|
||||
"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;
|
||||
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 = "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 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 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/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 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";
|
||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
||||
"/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/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/invite": { POST: ApiContract<typeof import("../api/invite.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"]>;
|
||||
}
|
||||
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",
|
||||
"version": "0.8.0",
|
||||
"wrnexus": {
|
||||
"version": "0.9.0"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
const config: AppConfig = {
|
||||
frameworkBehaviour: 1,
|
||||
compatibilityDate: "2026-08-02",
|
||||
head: [
|
||||
// --- Use a CSS framework via CDN (uncomment one) ---
|
||||
// Bootstrap:
|
||||
|
||||
@@ -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;
|
||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
@@ -25,17 +25,17 @@ component SignIn {
|
||||
class = ""
|
||||
}
|
||||
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}'>
|
||||
<header class="mb-6 space-y-1.5">
|
||||
<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="wrn-auth-sign-in__header mb-6 space-y-1.5">
|
||||
<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>
|
||||
</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="deviceFingerprint" 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}" />
|
||||
<div class="space-y-1.5">
|
||||
<div>
|
||||
<TogglePassword
|
||||
label="{passwordLabel}"
|
||||
cornerHint="Forgot password?"
|
||||
@@ -48,9 +48,7 @@ component SignIn {
|
||||
size="{size}"
|
||||
/>
|
||||
</div>
|
||||
{#if showRemember}
|
||||
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
|
||||
{/if}
|
||||
{#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}
|
||||
<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>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
@@ -64,4 +62,18 @@ component SignIn {
|
||||
{/if}
|
||||
</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
|
||||
{...attrs}
|
||||
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
|
||||
class="mb-6 space-y-1.5"
|
||||
@@ -50,7 +50,7 @@ component SignUp {
|
||||
data-schema='{schema}'
|
||||
data-redirect='{redirect}'
|
||||
novalidate
|
||||
class="grid gap-4 sm:grid-cols-2"
|
||||
class="wrn-auth-sign-up__form"
|
||||
>
|
||||
<Input
|
||||
id="auth-sign-up-name"
|
||||
@@ -61,7 +61,6 @@ component SignUp {
|
||||
icon="icon-[lucide--user]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -100,7 +99,6 @@ component SignUp {
|
||||
icon="icon-[lucide--circle-user-round]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -176,15 +174,7 @@ component SignUp {
|
||||
>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="{submitLabel}"
|
||||
variant="solid"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
fullWidth="true"
|
||||
class="wrn-auth-sign-up__wide sm:col-span-2"
|
||||
/>
|
||||
<div class="wrn-auth-sign-up__submit"><Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" /></div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
@@ -196,9 +186,19 @@ component SignUp {
|
||||
}
|
||||
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
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",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -160,10 +160,6 @@ export interface AuthEngine {
|
||||
key: string;
|
||||
response: unknown;
|
||||
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>;
|
||||
beginPasskeyAuthentication(input: {
|
||||
@@ -176,10 +172,6 @@ export interface AuthEngine {
|
||||
response: unknown;
|
||||
/** Request metadata used only for the resulting session. */
|
||||
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>;
|
||||
changePassword(
|
||||
userId: string,
|
||||
@@ -832,7 +824,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
store,
|
||||
onSignedIn: options.onSignedIn,
|
||||
onSignedOut: options.onSignedOut,
|
||||
onSuccessfulSignUp: options.onSuccessfulSignUp ?? options.onSuccessfullSignUp,
|
||||
onSuccessfulSignUp: options.onSuccessfulSignUp,
|
||||
|
||||
async register(input) {
|
||||
try {
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
getAuthUser,
|
||||
} from "../middleware.ts";
|
||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "../types.ts";
|
||||
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
@@ -53,18 +49,14 @@ export interface AuthHttpOptions {
|
||||
baseUrl?: string;
|
||||
schemas?: AuthSchemaOverrides | AuthSchemaSet;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const engine = options.engine;
|
||||
const schemas = resolveAuthSchemas(options.schemas);
|
||||
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
|
||||
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
|
||||
const onSignedIn = engine.onSignedIn;
|
||||
const onSignedOut = engine.onSignedOut;
|
||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||
|
||||
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 type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "./types.ts";
|
||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
||||
import {
|
||||
clearDefaultAuthEngine,
|
||||
@@ -52,10 +48,6 @@ export interface AuthConfig {
|
||||
baseUrl?: string;
|
||||
csrf?: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
||||
sessionCookieName?: string;
|
||||
/** Customize the package forward-auth verification response. */
|
||||
@@ -94,8 +86,6 @@ interface ResolvedAuthConfig {
|
||||
baseUrl?: string;
|
||||
csrf: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
@@ -156,8 +146,6 @@ function resolveConfig(
|
||||
baseUrl: raw.baseUrl,
|
||||
csrf: raw.csrf ?? true,
|
||||
passkey: raw.passkey,
|
||||
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
|
||||
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
|
||||
sessionCookieName: raw.sessionCookieName,
|
||||
onSessionVerification: raw.onSessionVerification,
|
||||
};
|
||||
@@ -392,10 +380,6 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
||||
|
||||
passkey: value.passkey,
|
||||
|
||||
onSignedIn: value.onSignedIn,
|
||||
|
||||
onSignedOut: value.onSignedOut,
|
||||
|
||||
sessionCookieName: value.sessionCookieName,
|
||||
|
||||
onSessionVerification: value.onSessionVerification,
|
||||
|
||||
@@ -60,8 +60,6 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
|
||||
schemas: getDefaultAuthSchemas(),
|
||||
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
|
||||
passkey: routeOptions.passkey,
|
||||
onSignedIn: routeOptions.onSignedIn,
|
||||
onSignedOut: routeOptions.onSignedOut,
|
||||
onSessionVerification: routeOptions.onSessionVerification,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||
@@ -8,10 +7,6 @@ export interface DefaultAuthRouteOptions {
|
||||
baseUrl?: string;
|
||||
csrf?: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
|
||||
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
|
||||
|
||||
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
@@ -420,8 +420,6 @@ export interface AuthEngineOptions {
|
||||
* redirects to /sign-in.
|
||||
*/
|
||||
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||
/** @deprecated Misspelled alias; use onSuccessfulSignUp. */
|
||||
onSuccessfullSignUp?: AuthSuccessfulSignUpHandler;
|
||||
breachProvider?: PasswordBreachProvider;
|
||||
passkeys?: PasskeyProvider;
|
||||
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
||||
|
||||
@@ -72,6 +72,17 @@ test("account forms use the dedicated password components", () => {
|
||||
expect(signUp).not.toContain('id="auth-sign-up-password"');
|
||||
});
|
||||
|
||||
test("account forms own consistent spacing and full-width submit layout", () => {
|
||||
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
|
||||
const signUp = readFileSync(join(directory, "SignUp.wrn"), "utf8");
|
||||
|
||||
expect(signIn).toContain("wrn-auth-sign-in__form");
|
||||
expect(signIn).toContain("wrn-auth-sign-in__remember");
|
||||
expect(signUp).toContain("wrn-auth-sign-up__form");
|
||||
expect(signUp).toContain("wrn-auth-sign-up__submit");
|
||||
expect(signUp).toContain("grid-template-columns: minmax(0, 1fr)");
|
||||
});
|
||||
|
||||
test("authentication journeys use themed UI form controls", () => {
|
||||
const expected = {
|
||||
"SignIn.wrn": ["<Input", "<TogglePassword", "<Checkbox", "<Button"],
|
||||
|
||||
@@ -579,8 +579,6 @@ describe("authentication engine", () => {
|
||||
await engine.finishPasskeyRegistration(registered.user!.id, {
|
||||
key: start.key,
|
||||
response: {},
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
}),
|
||||
).toBe(true);
|
||||
const auth = await engine.beginPasskeyAuthentication({
|
||||
@@ -595,8 +593,6 @@ describe("authentication engine", () => {
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: auth.key,
|
||||
response: { id: "cred-1" },
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { createPluginRunner } from "@wrnexus/plugin";
|
||||
import { authPlugin } from "../src/plugin.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { getDefaultAuthRouteOptions } from "../src/runtime.ts";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { invokeAuthHandler } from "../src/routes/api.ts";
|
||||
|
||||
function routeContext(request: Request): Context {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
req: request,
|
||||
url: new URL(request.url),
|
||||
params: {},
|
||||
locals: {},
|
||||
lang: "en",
|
||||
t: (key: string) => key,
|
||||
ip: "127.0.0.1",
|
||||
user: null,
|
||||
cookies: {
|
||||
get: (name: string) => (name === "wrn-csrf" ? "plugin-test-csrf-token" : undefined),
|
||||
} as Context["cookies"],
|
||||
localStorage: {} as Context["localStorage"],
|
||||
session: {
|
||||
id: () => "plugin-test-session",
|
||||
get: <T>(key: string) => values.get(key) as T | undefined,
|
||||
getAll: () => Object.fromEntries(values),
|
||||
set: (key: string, value: unknown) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
regenerate: () => {},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
},
|
||||
} as Context;
|
||||
}
|
||||
|
||||
test("plugin contributes components, runtime, styles, migration, and toolbar", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
@@ -70,9 +106,25 @@ test("config.auth controls route groups and migrations without explicit plugin o
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("config.auth resolves navigation hooks from the configured engine", async () => {
|
||||
const onSignedIn = () => new Response(null, { status: 204 });
|
||||
const onSignedOut = () => new Response(null, { status: 204 });
|
||||
test("a hook set on config.auth.engine fires when a request goes through the route layer", async () => {
|
||||
const calls: string[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "plugin-navigation-hooks-secret-longer-than-thirty-two-characters",
|
||||
onSignedIn(ctx, returnTo) {
|
||||
calls.push(`in:${returnTo}`);
|
||||
return Response.redirect(new URL(returnTo ?? "/account", ctx.url), 303);
|
||||
},
|
||||
onSignedOut(ctx) {
|
||||
calls.push("out");
|
||||
return Response.redirect(new URL("/sign-in", ctx.url), 303);
|
||||
},
|
||||
});
|
||||
await engine.register({
|
||||
email: "plugin-navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
@@ -82,14 +134,41 @@ test("config.auth resolves navigation hooks from the configured engine", async (
|
||||
});
|
||||
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: { onSignedIn, onSignedOut } as never,
|
||||
routes: true,
|
||||
},
|
||||
auth: { engine, routes: true },
|
||||
});
|
||||
|
||||
expect(getDefaultAuthRouteOptions().onSignedIn).toBe(onSignedIn);
|
||||
expect(getDefaultAuthRouteOptions().onSignedOut).toBe(onSignedOut);
|
||||
const loginResponse = await invokeAuthHandler(
|
||||
"login",
|
||||
routeContext(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": "plugin-test-csrf-token",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: "plugin-navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
returnTo: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(loginResponse.status).toBe(303);
|
||||
expect(loginResponse.headers.get("location")).toBe("https://example.test/dashboard");
|
||||
|
||||
const logoutResponse = await invokeAuthHandler(
|
||||
"logout",
|
||||
routeContext(
|
||||
new Request("https://example.test/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "x-csrf-token": "plugin-test-csrf-token" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(logoutResponse.status).toBe(303);
|
||||
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
|
||||
expect(calls).toEqual(["in:/dashboard", "out"]);
|
||||
});
|
||||
|
||||
test("auth runtime contains built-in browser schemas", async () => {
|
||||
|
||||
@@ -83,17 +83,10 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
|
||||
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
|
||||
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
Compatibility upgrades never happen implicitly. New applications pin
|
||||
`compatibilityDate` and `frameworkBehaviour`; existing applications use
|
||||
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.42",
|
||||
"version": "0.8.47",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type DeploymentRuntime,
|
||||
runtimeCapabilities,
|
||||
resolveWrnImports,
|
||||
stripBrowserTypes,
|
||||
} from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
@@ -324,7 +325,9 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
writeFileSync(out, code, "utf8");
|
||||
writeFileSync(clientEntry, browserCode, "utf8");
|
||||
// The entry is .mjs, so anything TypeScript left in a client function
|
||||
// body would be read back as JavaScript and fail to parse.
|
||||
writeFileSync(clientEntry, stripBrowserTypes(browserCode), "utf8");
|
||||
const browserResult = await Bun.build({
|
||||
entrypoints: [clientEntry],
|
||||
target: "browser",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import {
|
||||
CURRENT_COMPATIBILITY_DATE,
|
||||
CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
loadRawConfig,
|
||||
resolveCompatibility,
|
||||
} from "@wrnexus/styles";
|
||||
|
||||
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
|
||||
function configPath(root: string): string | undefined {
|
||||
return CONFIG_NAMES.map((name) => join(root, name)).find(existsSync);
|
||||
}
|
||||
|
||||
export async function compatibilityReport(appRoot: string) {
|
||||
return resolveCompatibility(await loadRawConfig(resolve(appRoot)));
|
||||
}
|
||||
|
||||
export function upgradeCompatibility(appRoot: string): {
|
||||
file: string;
|
||||
backup: string;
|
||||
changed: boolean;
|
||||
} {
|
||||
const root = resolve(appRoot);
|
||||
const file = configPath(root);
|
||||
if (!file) throw new Error("WRN-COMPATIBILITY-NO-CONFIG: wrnexus.config.ts was not found.");
|
||||
const source = readFileSync(file, "utf8");
|
||||
let updated = source;
|
||||
const replace = (name: string, value: string) => {
|
||||
const pattern = new RegExp(`(^\\s*${name}\\s*:\\s*)(?:["'][^"']*["']|\\d+)(\\s*,?)`, "m");
|
||||
if (pattern.test(updated)) updated = updated.replace(pattern, `$1${value}$2`);
|
||||
else {
|
||||
const object = /(?:const\s+config[^=]*=|defineConfig\s*\(|export\s+default)\s*\{/m;
|
||||
if (!object.test(updated))
|
||||
throw new Error("WRN-COMPATIBILITY-CONFIG-SHAPE: unable to locate the root config object.");
|
||||
updated = updated.replace(object, (match) => `${match}\n ${name}: ${value},`);
|
||||
}
|
||||
};
|
||||
replace("compatibilityDate", JSON.stringify(CURRENT_COMPATIBILITY_DATE));
|
||||
replace("frameworkBehaviour", String(CURRENT_FRAMEWORK_BEHAVIOUR));
|
||||
if (updated === source) return { file, backup: "", changed: false };
|
||||
const directory = join(root, ".wrnexus", "compatibility-backups");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const backup = join(directory, `${Date.now()}-${basename(file)}`);
|
||||
copyFileSync(file, backup);
|
||||
writeFileSync(file, updated, "utf8");
|
||||
return { file, backup, changed: true };
|
||||
}
|
||||
|
||||
export async function runCompatibilityCommand(
|
||||
appRoot: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (command === "upgrade") {
|
||||
const result = upgradeCompatibility(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
||||
else
|
||||
console.log(
|
||||
result.changed
|
||||
? `✓ Compatibility policy upgraded\n backup: ${result.backup}`
|
||||
: "✓ Compatibility policy already current",
|
||||
);
|
||||
}
|
||||
const report = await compatibilityReport(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`Compatibility date: ${report.effectiveDate} (current ${report.currentDate})`);
|
||||
console.log(
|
||||
`Framework behaviour: ${report.effectiveBehaviour} (current ${report.currentBehaviour})`,
|
||||
);
|
||||
for (const message of report.messages) console.log(`- ${message}`);
|
||||
}
|
||||
return !report.needsUpgrade && !report.future;
|
||||
}
|
||||
@@ -260,8 +260,6 @@ trim_trailing_whitespace = true
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -275,14 +273,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
+72
-5
@@ -14,15 +14,23 @@
|
||||
* (`databases.<name>`), with files under `app/db/<name>/`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createPluginRunner,
|
||||
discoverPlugins,
|
||||
type PackageMigrationDefinition,
|
||||
} from "@wrnexus/plugin";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
analyzeMigrations,
|
||||
appliedMigrations,
|
||||
applyMigrations,
|
||||
loadMigrations,
|
||||
migrate,
|
||||
parseMigration,
|
||||
parseQueries,
|
||||
rollback,
|
||||
scaffoldMigration,
|
||||
@@ -42,6 +50,55 @@ function dbBaseOf(appDir: string, dbName: string | null): string {
|
||||
return dbName ? join(appDir, "db", dbName) : join(appDir, "db");
|
||||
}
|
||||
|
||||
function packageMigrationName(definition: PackageMigrationDefinition, name?: string): string {
|
||||
const clean = (value: string) => value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||
return name ? `${clean(definition.id)}__${clean(name)}` : clean(definition.id);
|
||||
}
|
||||
|
||||
function resolvePackageMigrations(
|
||||
definitions: readonly PackageMigrationDefinition[],
|
||||
database?: string,
|
||||
) {
|
||||
return definitions.flatMap((definition) => {
|
||||
if ((definition.database?.trim() || "default") !== (database?.trim() || "default")) return [];
|
||||
if (definition.source !== undefined) {
|
||||
return [parseMigration(packageMigrationName(definition), definition.source)];
|
||||
}
|
||||
if (!definition.entry || !existsSync(definition.entry)) {
|
||||
throw new Error(`WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} has no readable entry.`);
|
||||
}
|
||||
if (statSync(definition.entry).isDirectory()) {
|
||||
return loadMigrations(definition.entry).map((migration) => ({
|
||||
...migration,
|
||||
name: packageMigrationName(definition, migration.name),
|
||||
}));
|
||||
}
|
||||
return [
|
||||
parseMigration(
|
||||
packageMigrationName(definition, basename(definition.entry, ".sql")),
|
||||
readFileSync(definition.entry, "utf8"),
|
||||
),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function packageMigrations(root: string, config: AppConfig, database?: string) {
|
||||
const discovered = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(discovered, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
return resolvePackageMigrations((await runner.contributions()).migrations, database);
|
||||
}
|
||||
|
||||
/** List user tables for the connected database (dialect-aware introspection). */
|
||||
async function listTables(db: import("@wrnexus/db").Db): Promise<string[]> {
|
||||
const dialect = db.driver.dialect;
|
||||
@@ -126,6 +183,7 @@ export async function runDbCommand(
|
||||
const dbConfig = dbName ? config.databases?.[dbName] : config.db;
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const contributedMigrations = await packageMigrations(root, config, dbName ?? undefined);
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
const safetyIssues = analyzeMigrations(loadMigrations(migrationsDir));
|
||||
|
||||
@@ -242,7 +300,10 @@ export async function runDbCommand(
|
||||
`WRN-DB-UNSAFE-MIGRATION: ${blockers.length} breaking rollout operation(s) found. Run 'wrnexus db check' and use an expand/contract migration; --allow-breaking explicitly overrides this gate.`,
|
||||
);
|
||||
}
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
const applied = [
|
||||
...(await migrate(db, migrationsDir)),
|
||||
...(await applyMigrations(db, contributedMigrations)),
|
||||
];
|
||||
console.log(
|
||||
applied.length
|
||||
? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}`
|
||||
@@ -257,8 +318,14 @@ export async function runDbCommand(
|
||||
}
|
||||
case "status": {
|
||||
const rows = await status(db, migrationsDir);
|
||||
if (rows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of rows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const packageRows = contributedMigrations.map(({ name }) => ({
|
||||
name,
|
||||
applied: applied.has(name),
|
||||
}));
|
||||
const allRows = [...rows, ...packageRows];
|
||||
if (allRows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of allRows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -72,8 +72,6 @@ Usage:
|
||||
Run unit | component | api | browser | visual | accessibility | performance
|
||||
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] [--fix] Check project health; optionally apply safe repairs
|
||||
wrnexus compatibility <check|explain|upgrade> [app-dir]
|
||||
Inspect or explicitly upgrade behavior defaults
|
||||
wrnexus contracts <check|snapshot> [app-dir]
|
||||
Detect breaking boundary contract changes
|
||||
wrnexus security <audit|headers|test> [app-dir]
|
||||
@@ -292,16 +290,6 @@ async function main(): Promise<void> {
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "compatibility": {
|
||||
const { runCompatibilityCommand } = await import("./compatibility-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
||||
if (!["check", "explain", "upgrade"].includes(subcommand))
|
||||
throw new Error(`WRN-COMPATIBILITY-COMMAND: unknown command '${subcommand}'.`);
|
||||
const current = await runCompatibilityCommand(appRoot, subcommand, rest);
|
||||
if (!current && subcommand !== "explain") process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "contracts": {
|
||||
const { runContractsCommand } = await import("./contracts-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Migration: move `api` entries out of legacy `ssr { … }` / `client { … }`
|
||||
* data blocks into a single page-level `apis { }` block.
|
||||
*
|
||||
* IMPORTANT: this is a text-level transform, not a parse-and-rewrite. The
|
||||
* `@wrnexus/syntax` parser deliberately THROWS a `ParseError` on `ssr { … }`
|
||||
* / `client { … }` data blocks (see `packages/syntax/src/parser.ts`, the
|
||||
* `case "ssr": case "client": case "server":` handling) — that legacy syntax
|
||||
* is exactly what this migration exists to read, so it cannot be reached by
|
||||
* parsing first. Instead this scans the source with the same balanced-brace
|
||||
* technique used throughout `update.ts` (`findMatching`), and only parses
|
||||
* the RESULT, as a validity check.
|
||||
*/
|
||||
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
/** Blank out string/template literal contents and comments, preserving length. */
|
||||
function maskLiteralsAndComments(source: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
const quote = char;
|
||||
let end = index + 1;
|
||||
while (end < source.length) {
|
||||
if (source[end] === "\\") {
|
||||
end += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[end] === quote) {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "/") {
|
||||
let end = index;
|
||||
while (end < source.length && source[end] !== "\n") end++;
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "*") {
|
||||
let end = source.indexOf("*/", index + 2);
|
||||
end = end < 0 ? source.length : end + 2;
|
||||
out += source.slice(index, end).replace(/[^\n]/g, " ");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
out += char;
|
||||
index++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
/** Start of the `ssr`/`client` keyword. */
|
||||
start: number;
|
||||
/** One past the block's closing `}`. */
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface ApiEntry {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** `name METHOD path { … }` text, without the leading `api` keyword. */
|
||||
text: string;
|
||||
/** Offsets relative to the block body the entry was found in. */
|
||||
localStart: number;
|
||||
localEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find `api <name> <METHOD> <path> { … }` entries inside a mode-block body.
|
||||
*
|
||||
* A legacy BARE-BODY entry (no `request`/`response`/`error` sections — its
|
||||
* body is JS evaluated inside `with ($data ?? {})`) is deliberately excluded
|
||||
* here. The `apis { }` grammar requires sections, so folding a bare body into
|
||||
* it would produce source that fails to parse — a spurious "failed to parse"
|
||||
* report for a file that is actually fine. `report-legacy-api-bodies`
|
||||
* (`legacy-api-body.ts`) is the migration that surfaces these for manual
|
||||
* review; leaving them out of this scan lets that block's leftover content
|
||||
* fall through to the "mixes content" skip below when needed, or leaves the
|
||||
* block entirely untouched when it holds only bare-body entries.
|
||||
*/
|
||||
function findApiEntries(body: string): ApiEntry[] {
|
||||
const entries: ApiEntry[] = [];
|
||||
const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`);
|
||||
}
|
||||
const localStart = match.index;
|
||||
const localEnd = close + 1;
|
||||
const bodyText = body.slice(open + 1, close);
|
||||
const hasSections = /\b(request|response|error)\s*\{/.test(maskLiteralsAndComments(bodyText));
|
||||
if (!hasSections) {
|
||||
header.lastIndex = localEnd;
|
||||
continue;
|
||||
}
|
||||
const text = body.slice(localStart, localEnd).replace(/^api\s+/, "");
|
||||
entries.push({
|
||||
name: match[1]!,
|
||||
method: match[2]!.toUpperCase(),
|
||||
path: match[3]!,
|
||||
text,
|
||||
localStart,
|
||||
localEnd,
|
||||
});
|
||||
header.lastIndex = localEnd;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (const edit of sorted) {
|
||||
output += source.slice(cursor, edit.start) + edit.replacement;
|
||||
cursor = edit.end;
|
||||
}
|
||||
output += source.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function migrateApisBlock(
|
||||
source: string,
|
||||
): { source: string; changed: boolean } | { skip: string } {
|
||||
const blocks = findModeBlocks(source);
|
||||
if (blocks.length === 0) return { source, changed: false };
|
||||
|
||||
const allEntries: ApiEntry[] = [];
|
||||
const nameMode = new Map<string, "ssr" | "client">();
|
||||
const perBlockEntries = new Map<ModeBlock, ApiEntry[]>();
|
||||
|
||||
for (const block of blocks) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
const entries = findApiEntries(body);
|
||||
if (entries.length === 0) continue;
|
||||
perBlockEntries.set(block, entries);
|
||||
for (const entry of entries) {
|
||||
const existingMode = nameMode.get(entry.name);
|
||||
if (existingMode && existingMode !== block.mode) {
|
||||
return {
|
||||
skip: `api '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`,
|
||||
};
|
||||
}
|
||||
nameMode.set(entry.name, block.mode);
|
||||
allEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) return { source, changed: false };
|
||||
|
||||
// Locate an existing page-level `apis { }` block, if any.
|
||||
const apisMatch = /\bapis\s*\{/.exec(source);
|
||||
let existingApis: { bodyStart: number; bodyEnd: number } | null = null;
|
||||
if (apisMatch) {
|
||||
const open = apisMatch.index + apisMatch[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) throw new Error(`unterminated "apis {" block at offset ${apisMatch.index}`);
|
||||
existingApis = { bodyStart: open + 1, bodyEnd: close };
|
||||
}
|
||||
|
||||
const designatedBlock = blocks.find((block) => perBlockEntries.has(block));
|
||||
|
||||
const edits: Edit[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const entries = perBlockEntries.get(block);
|
||||
if (!entries) continue; // block had no api entries; leave it untouched entirely
|
||||
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
let remainder = body;
|
||||
for (const entry of [...entries].reverse()) {
|
||||
remainder = remainder.slice(0, entry.localStart) + remainder.slice(entry.localEnd);
|
||||
}
|
||||
if (remainder.trim() !== "") {
|
||||
// A bare `ssr { … }` / `client { … }` block is no longer valid syntax at
|
||||
// all once its api entries are gone -- only "state" and hydrate forms
|
||||
// survive. Leftover content (e.g. `functions { }`) belongs to the
|
||||
// move-mode-functions migration; migrating api entries alone here would
|
||||
// strand it inside a block shape the parser rejects. Skip rather than
|
||||
// partially rewrite.
|
||||
return {
|
||||
skip: `${block.mode} { … } mixes api entries with other content (e.g. functions) that must be migrated first`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!existingApis && block === designatedBlock) {
|
||||
const apisBody = allEntries.map((entry) => ` ${entry.text}`).join("\n\n");
|
||||
const apisBlockText = `apis {\n${apisBody}\n }`;
|
||||
edits.push({ start: block.start, end: block.end, replacement: apisBlockText });
|
||||
} else {
|
||||
edits.push({ start: block.start, end: block.end, replacement: "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (existingApis) {
|
||||
const existingBody = source.slice(existingApis.bodyStart, existingApis.bodyEnd);
|
||||
const additions = allEntries.map((entry) => ` ${entry.text}`).join("\n\n");
|
||||
const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `;
|
||||
edits.push({
|
||||
start: existingApis.bodyStart,
|
||||
end: existingApis.bodyEnd,
|
||||
replacement: mergedBody,
|
||||
});
|
||||
}
|
||||
|
||||
let after = applyEdits(source, edits);
|
||||
after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
if (after === source) return { source, changed: false };
|
||||
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`apis-block migration produced source that failed to parse: ${message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return { source: after, changed: true };
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Detection (not migration) for legacy bare-body `api` entries.
|
||||
*
|
||||
* A legacy bare `api` body — one with no `request { }` / `response { }` /
|
||||
* `error { }` sections — is evaluated inside `with ($data ?? {})`, so it
|
||||
* references payload fields as bare identifiers. Converting
|
||||
* `return userNames(users)` correctly needs `data.users`, but nothing in the
|
||||
* source distinguishes `users` (a payload field) from `userNames` (a 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 silently
|
||||
* wrong, so this module deliberately reports free identifiers rather than
|
||||
* rewriting anything.
|
||||
*
|
||||
* Like `apis-block.ts` / `mode-functions.ts`, this is a text-level scan, not
|
||||
* a parse-and-rewrite: the `@wrnexus/syntax` parser THROWS on `ssr { … }` /
|
||||
* `client { … }` data blocks, which is exactly the legacy syntax this module
|
||||
* reads. It uses the same balanced-brace technique (`findMatching`) used
|
||||
* throughout `update.ts`.
|
||||
*/
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
start: number;
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface LegacyApiEntry {
|
||||
name: string;
|
||||
/** True when the entry declares request/response/error sections. */
|
||||
hasSections: boolean;
|
||||
/** The entry's body text (between its outer braces). */
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
/** Find `api <name> <METHOD> <path> { … }` entries inside a mode-block body. */
|
||||
function findApiEntries(body: string): LegacyApiEntry[] {
|
||||
const entries: LegacyApiEntry[] = [];
|
||||
const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`);
|
||||
}
|
||||
const bodyText = body.slice(open + 1, close);
|
||||
const maskedBodyText = maskLiteralsAndComments(bodyText);
|
||||
const hasSections = /\b(request|response|error)\s*\{/.test(maskedBodyText);
|
||||
entries.push({ name: match[1]!, hasSections, bodyText });
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Blank out string/template literal contents and comments, preserving length. */
|
||||
function maskLiteralsAndComments(source: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
const quote = char;
|
||||
let end = index + 1;
|
||||
while (end < source.length) {
|
||||
if (source[end] === "\\") {
|
||||
end += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[end] === quote) {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "/") {
|
||||
let end = index;
|
||||
while (end < source.length && source[end] !== "\n") end++;
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "*") {
|
||||
let end = source.indexOf("*/", index + 2);
|
||||
end = end < 0 ? source.length : end + 2;
|
||||
out += source.slice(index, end).replace(/[^\n]/g, " ");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
out += char;
|
||||
index++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const JS_KEYWORDS = new Set([
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"of",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"undefined",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
"async",
|
||||
"await",
|
||||
"get",
|
||||
"set",
|
||||
]);
|
||||
|
||||
const JS_GLOBALS = new Set([
|
||||
"Math",
|
||||
"JSON",
|
||||
"Object",
|
||||
"Array",
|
||||
"String",
|
||||
"Number",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Map",
|
||||
"Set",
|
||||
"Promise",
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"EvalError",
|
||||
"ReferenceError",
|
||||
"URIError",
|
||||
"console",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"globalThis",
|
||||
"Symbol",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"Proxy",
|
||||
"Reflect",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"isNaN",
|
||||
"isFinite",
|
||||
"encodeURIComponent",
|
||||
"decodeURIComponent",
|
||||
"encodeURI",
|
||||
"decodeURI",
|
||||
"structuredClone",
|
||||
"BigInt",
|
||||
"ArrayBuffer",
|
||||
"Int8Array",
|
||||
"Uint8Array",
|
||||
"Int16Array",
|
||||
"Uint16Array",
|
||||
"Int32Array",
|
||||
"Uint32Array",
|
||||
"Float32Array",
|
||||
"Float64Array",
|
||||
"DataView",
|
||||
]);
|
||||
|
||||
/** Identifiers that name a param, are function names, or are declared via const/let/var/catch. */
|
||||
function collectDeclaredNames(masked: string): Set<string> {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const m of masked.matchAll(/\bfunction\s*(?:\*\s*)?([A-Za-z_$][\w$]*)?\s*\(([^)]*)\)/g)) {
|
||||
if (m[1]) names.add(m[1]);
|
||||
for (const name of extractIdentifiers(m[2] ?? "")) names.add(name);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\(([^)]*)\)\s*=>/g)) {
|
||||
for (const name of extractIdentifiers(m[1] ?? "")) names.add(name);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) {
|
||||
names.add(m[1]!);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\b(?:const|let|var)\s+([^;\n]+)/g)) {
|
||||
for (const part of m[1]!.split(",")) {
|
||||
const declarator = part.split("=")[0]!;
|
||||
for (const name of extractIdentifiers(declarator)) names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
|
||||
names.add(m[1]!);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractIdentifiers(text: string): string[] {
|
||||
return Array.from(text.matchAll(/[A-Za-z_$][\w$]*/g), (m) => m[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiers a bare `api` body references that are neither declared locally
|
||||
* (params, `const`/`let`/`var`, function names, catch bindings) nor
|
||||
* JavaScript globals. Property-access keys (`u.name` → `u`, not `name`) and
|
||||
* object-literal keys are excluded; string/template contents never reach the
|
||||
* scan at all.
|
||||
*/
|
||||
function freeIdentifiersOf(body: string): string[] {
|
||||
const masked = maskLiteralsAndComments(body);
|
||||
const declared = collectDeclaredNames(masked);
|
||||
const free = new Set<string>();
|
||||
const identifier = /[A-Za-z_$][\w$]*/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = identifier.exec(masked))) {
|
||||
const name = match[0];
|
||||
if (JS_KEYWORDS.has(name)) continue;
|
||||
|
||||
let before = match.index - 1;
|
||||
while (before >= 0 && /\s/.test(masked[before]!)) before--;
|
||||
if (before >= 0 && masked[before] === ".") continue; // property access
|
||||
|
||||
let after = match.index + name.length;
|
||||
while (after < masked.length && /\s/.test(masked[after]!)) after++;
|
||||
if (masked[after] === ":" && masked[after + 1] !== ":") {
|
||||
if (before >= 0 && (masked[before] === "{" || masked[before] === ",")) continue; // object key
|
||||
}
|
||||
|
||||
if (declared.has(name)) continue;
|
||||
if (JS_GLOBALS.has(name)) continue;
|
||||
free.add(name);
|
||||
}
|
||||
return [...free];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find legacy bare-body `api` entries — those with no `request`/`response`/
|
||||
* `error` sections — and report each one's name and free identifiers.
|
||||
* Writes nothing; the caller decides what to do with the report.
|
||||
*/
|
||||
export function detectLegacyApiBodies(
|
||||
source: string,
|
||||
): { name: string; freeIdentifiers: string[] }[] {
|
||||
const results: { name: string; freeIdentifiers: string[] }[] = [];
|
||||
for (const block of findModeBlocks(source)) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
for (const entry of findApiEntries(body)) {
|
||||
if (entry.hasSections) continue;
|
||||
results.push({ name: entry.name, freeIdentifiers: freeIdentifiersOf(entry.bodyText) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Migration: move mode-scoped helpers out of legacy `ssr { functions { … } }`
|
||||
* / `client { functions { … } }` blocks into the page-level `functions { }`
|
||||
* block, tagged `shared`.
|
||||
*
|
||||
* IMPORTANT: this is a text-level transform, not a parse-and-rewrite, for the
|
||||
* same reason as `apis-block.ts`: the `@wrnexus/syntax` parser deliberately
|
||||
* THROWS a `ParseError` on `ssr { … }` / `client { … }` data blocks — that
|
||||
* legacy syntax is exactly what this migration exists to read, so it cannot
|
||||
* be reached by parsing first. Instead this scans the source with the same
|
||||
* balanced-brace technique used throughout `update.ts` (`findMatching`), and
|
||||
* only parses the RESULT, as a validity check.
|
||||
*/
|
||||
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Net `{` minus `}` outside string literals -- a splice that loses a brace changes it. */
|
||||
function braceBalance(source: string): number {
|
||||
let balance = 0;
|
||||
let quote = "";
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === "{") balance++;
|
||||
else if (char === "}") balance--;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
/** Start of the `ssr`/`client` keyword. */
|
||||
start: number;
|
||||
/** One past the block's closing `}`. */
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface FunctionsSubBlock {
|
||||
/** Offsets relative to the mode block's body. */
|
||||
localStart: number;
|
||||
localEnd: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find `functions { … }` sub-blocks inside a mode-block body. */
|
||||
function findFunctionsSubBlocks(body: string): FunctionsSubBlock[] {
|
||||
const blocks: FunctionsSubBlock[] = [];
|
||||
const header = /\bfunctions\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "functions {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
localStart: match.index,
|
||||
localEnd: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface FunctionEntry {
|
||||
name: string;
|
||||
/** `function name(...) { ... }` text, with any leading runtime keyword stripped. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Find `[client|server|shared] function <name>(...) { ... }` entries inside a functions-block body. */
|
||||
function findFunctionEntries(body: string): FunctionEntry[] {
|
||||
const entries: FunctionEntry[] = [];
|
||||
const header = /\b(?:(?:client|server|shared)\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const parenOpen = match.index + match[0].length - 1;
|
||||
const parenClose = findMatching(body, parenOpen, "(", ")");
|
||||
if (parenClose < 0) {
|
||||
throw new Error(`unterminated function parameter list at offset ${match.index}`);
|
||||
}
|
||||
const braceOpen = body.indexOf("{", parenClose + 1);
|
||||
if (braceOpen < 0) {
|
||||
throw new Error(`unterminated function body at offset ${match.index}`);
|
||||
}
|
||||
const braceClose = findMatching(body, braceOpen);
|
||||
if (braceClose < 0) {
|
||||
throw new Error(`unterminated function body at offset ${match.index}`);
|
||||
}
|
||||
const localStart = match.index;
|
||||
const localEnd = braceClose + 1;
|
||||
const text = body.slice(localStart, localEnd).replace(/^(client|server|shared)\s+/, "");
|
||||
entries.push({ name: match[1]!, text });
|
||||
header.lastIndex = localEnd;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (const edit of sorted) {
|
||||
output += source.slice(cursor, edit.start) + edit.replacement;
|
||||
cursor = edit.end;
|
||||
}
|
||||
output += source.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function migrateModeFunctions(
|
||||
source: string,
|
||||
): { source: string; changed: boolean } | { skip: string } {
|
||||
const blocks = findModeBlocks(source);
|
||||
if (blocks.length === 0) return { source, changed: false };
|
||||
|
||||
const allEntries: FunctionEntry[] = [];
|
||||
const nameMode = new Map<string, "ssr" | "client">();
|
||||
const perBlockFuncBlocks = new Map<ModeBlock, FunctionsSubBlock[]>();
|
||||
|
||||
for (const block of blocks) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
const funcBlocks = findFunctionsSubBlocks(body);
|
||||
if (funcBlocks.length === 0) continue;
|
||||
|
||||
for (const funcBlock of funcBlocks) {
|
||||
const funcBody = body.slice(funcBlock.bodyStart, funcBlock.bodyEnd);
|
||||
for (const entry of findFunctionEntries(funcBody)) {
|
||||
const existingMode = nameMode.get(entry.name);
|
||||
if (existingMode && existingMode !== block.mode) {
|
||||
return {
|
||||
skip: `function '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`,
|
||||
};
|
||||
}
|
||||
nameMode.set(entry.name, block.mode);
|
||||
allEntries.push(entry);
|
||||
}
|
||||
}
|
||||
perBlockFuncBlocks.set(block, funcBlocks);
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) return { source, changed: false };
|
||||
|
||||
// Locate an existing page-level `functions { }` block, i.e. one that is not
|
||||
// inside any ssr/client mode block.
|
||||
const insideAMode = (index: number) =>
|
||||
blocks.some((block) => index >= block.start && index < block.end);
|
||||
|
||||
let existingFunctions: { bodyStart: number; bodyEnd: number } | null = null;
|
||||
{
|
||||
const header = /\bfunctions\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
if (insideAMode(match.index)) continue;
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) throw new Error(`unterminated "functions {" block at offset ${match.index}`);
|
||||
existingFunctions = { bodyStart: open + 1, bodyEnd: close };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingFunctions) {
|
||||
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
|
||||
for (const entry of allEntries) {
|
||||
if (new RegExp(`\\bfunction\\s+${entry.name}\\b`).test(existingBody)) {
|
||||
return {
|
||||
skip: `'${entry.name}' already exists in the page-level functions block`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const designatedBlock = blocks.find((block) => perBlockFuncBlocks.has(block));
|
||||
const edits: Edit[] = [];
|
||||
// True when a mode block keeps non-function content (e.g. `api` entries)
|
||||
// after its functions are extracted. The grammar rejects any `ssr {}` /
|
||||
// `client {}` wrapper outright, regardless of what's inside, so such a
|
||||
// leftover wrapper cannot parse until move-api-blocks removes it in the
|
||||
// same `wrnexus update` run. Validating with `parse` here would therefore
|
||||
// always fail on a case this migration is specifically meant to unblock.
|
||||
let leavesModeWrapper = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
const funcBlocks = perBlockFuncBlocks.get(block);
|
||||
if (!funcBlocks) continue; // block had no functions sub-block; leave it untouched entirely
|
||||
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
let remainder = body;
|
||||
for (const funcBlock of [...funcBlocks].reverse()) {
|
||||
remainder = remainder.slice(0, funcBlock.localStart) + remainder.slice(funcBlock.localEnd);
|
||||
}
|
||||
const isEmpty = remainder.trim() === "";
|
||||
if (!isEmpty) leavesModeWrapper = true;
|
||||
|
||||
if (block === designatedBlock && !existingFunctions) {
|
||||
const funcsBody = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
|
||||
const funcsBlockText = `functions {\n${funcsBody}\n }`;
|
||||
const replacement = isEmpty
|
||||
? funcsBlockText
|
||||
: `${funcsBlockText}\n\n ${block.mode} {${remainder}}`;
|
||||
edits.push({ start: block.start, end: block.end, replacement });
|
||||
} else {
|
||||
const replacement = isEmpty ? "" : `${block.mode} {${remainder}}`;
|
||||
edits.push({ start: block.start, end: block.end, replacement });
|
||||
}
|
||||
}
|
||||
|
||||
if (existingFunctions) {
|
||||
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
|
||||
const additions = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
|
||||
const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `;
|
||||
edits.push({
|
||||
start: existingFunctions.bodyStart,
|
||||
end: existingFunctions.bodyEnd,
|
||||
replacement: mergedBody,
|
||||
});
|
||||
}
|
||||
|
||||
let after = applyEdits(source, edits);
|
||||
after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
if (after === source) return { source, changed: false };
|
||||
|
||||
if (leavesModeWrapper) {
|
||||
// The only path that writes without a parse check. A surviving `ssr {`/`client {`
|
||||
// wrapper still holding `api` entries is unparseable by design -- `move-api-blocks`
|
||||
// finishes the job later in the same run -- so `parse` cannot validate it here.
|
||||
// Brace balance is the one invariant still checkable, and it is what a bad splice
|
||||
// offset would break. Downstream nothing else would catch it: the parser rejects a
|
||||
// corrupted wrapper and an untouched one identically.
|
||||
if (braceBalance(after) !== braceBalance(source)) {
|
||||
throw new Error(
|
||||
"mode-functions migration unbalanced the source braces; refusing to write the file",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`mode-functions migration produced source that failed to parse: ${message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { source: after, changed: true };
|
||||
}
|
||||
+100
-1
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||
// included in each published CLI bundle.
|
||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||
import { regenerateRoutes } from "./routes.ts";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
@@ -99,6 +99,79 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type assertions for sectioned api blocks.
|
||||
*
|
||||
* Enforcement lives here rather than in the compiler because this file is under
|
||||
* `app/` and is therefore compiled by the project's own tsc, while generated
|
||||
* build artifacts are not type-checked at all.
|
||||
*/
|
||||
function pageSlug(path: string): string {
|
||||
// Block names are only unique within a single page (see apiBindingMap), so
|
||||
// two pages each declaring e.g. `api search` is legal and would otherwise
|
||||
// emit the identical `__wrn_api_check_search` type alias twice into this
|
||||
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
|
||||
// name with a short deterministic hash of the page's path (not the whole
|
||||
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
|
||||
// to keep names unique across the whole app while staying compact.
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
let hash = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function apiBlockAssertions(
|
||||
pages: { path: string; ast: PageAst }[],
|
||||
apiContracts: string,
|
||||
appDir: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// Hash the path relative to `app/`, not the absolute path: the absolute
|
||||
// path varies with where the project checkout lives (e.g. a CI runner's
|
||||
// temp clone vs. a developer's local path), which would make this file
|
||||
// spuriously "stale" every time it's regenerated somewhere else.
|
||||
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
|
||||
for (const block of page.ast.dataApis) {
|
||||
if (!block.sections) continue;
|
||||
|
||||
// A block with zero declared request fields would fall back to the
|
||||
// empty-shape `Record<string, never>` below. `keyof Record<string,
|
||||
// never>` is `string`, which makes the key-exactness arm of
|
||||
// AssertAssignable evaluate to `false` unconditionally and raises
|
||||
// TS2344 on every such block regardless of whether the block author
|
||||
// did anything wrong. We skip emission for any block with zero
|
||||
// declared request fields, since there is nothing to assert
|
||||
// type-safety about. This applies regardless of mode: `ssr` blocks can
|
||||
// never declare a `request` and so are always caught by this same
|
||||
// check; mode-less (`any`) and `client` blocks can declare fields and
|
||||
// get an assertion whenever they do. This is more honest about intent
|
||||
// than emitting a vacuous/always-failing check.
|
||||
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||
if (fields.length === 0) continue;
|
||||
|
||||
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||
console.warn(
|
||||
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||
);
|
||||
}
|
||||
|
||||
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
|
||||
|
||||
lines.push(
|
||||
`export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
|
||||
block.path,
|
||||
)}, ${JSON.stringify(block.method)}>>>;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateApplicationTypes(
|
||||
appRoot: string,
|
||||
pluginContributions?: PluginContributions,
|
||||
@@ -145,6 +218,10 @@ export function generateApplicationTypes(
|
||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||
path: file,
|
||||
ast: parse(readFileSync(file, "utf8")),
|
||||
}));
|
||||
const typeDir = join(app, "types");
|
||||
const apiContracts = router.api
|
||||
.map((route) => {
|
||||
@@ -218,11 +295,33 @@ declare namespace WRNexusGenerated {
|
||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||
? true
|
||||
: [Actual] extends [Expected]
|
||||
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
type __wrn_expect_true<T extends true> = T;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
}
|
||||
`;
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||
writeFileSync(output, code, "utf8");
|
||||
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
|
||||
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
|
||||
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
|
||||
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||
//
|
||||
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
|
||||
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
|
||||
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||
${apiBlockAssertions(pageAsts, apiContracts, app)}
|
||||
export {};
|
||||
`;
|
||||
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||
writePluginArtifacts(root, pluginContributions);
|
||||
return {
|
||||
file: relative(root, output).replace(/\\/g, "/"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user