Compare commits

..
43 Commits
Author SHA1 Message Date
Clintchiz cd0dffa87d feat: complete SSR CRM and refine auth UI
Quality / quality (ubuntu-latest) (push) Failing after 11m17s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-20 16:17:59 +05:30
ClintchizandClaude Opus 5 f57bd05a03 feat(language-server): complete and describe api block calls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:35:31 +05:30
ClintchizandClaude Opus 5 2bb52487eb feat(editor): complete the apis block and drop the removed snippets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:31:23 +05:30
ClintchizandClaude Opus 5 990a8128a7 feat(editor): highlight the apis block
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:31:19 +05:30
ClintchizandClaude Opus 5 7a1b4e5b33 fix(cli): guard the one migration write path that skips parse validation
mode-functions writes without a parse check when a mode wrapper survives
holding api entries, since that intermediate state is unparseable until
move-api-blocks runs later in the same pass. Brace balance is the invariant
a bad splice offset would break, so check that instead; nothing downstream
could tell a corrupted wrapper from an untouched one.

Also records that Task 5's example-app migration ran against an already-
migrated target and so did not prove end-to-end behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:24:19 +05:30
ClintchizandClaude Opus 5 aded2daab9 fix: restore the production gate after the migration tasks
- rebuild editors/vscode bundles, stale since the parser escape fix
- attach the caught ParseError as `cause` in both migration validators
- drop two unused test bindings flagged by eslint

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:19:53 +05:30
ClintchizandClaude Opus 5 6bb3ab5fe7 feat(cli): fail the update when a project needs manual review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:12:38 +05:30
ClintchizandClaude Opus 5 4aa0973352 feat(cli): report legacy api bodies for manual migration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:03:17 +05:30
ClintchizandClaude Opus 5 e616ed276e feat(cli): migrate mode-scoped helpers to shared functions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:58:16 +05:30
ClintchizandClaude Opus 5 74490964ee feat(cli): migrate api entries into the apis block
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:51:44 +05:30
ClintchizandClaude Opus 5 7a3e55b150 feat(cli): migrate away the dead config keys
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:45:40 +05:30
ClintchizandClaude Opus 5 fb24cc7ec3 fix(syntax): stop swallowing literal backslashes in attribute values
readQuoted treated \X as an escape for any X, so a single literal
backslash in any quoted attribute value was silently dropped
(data-path="C:\Users" parsed as C:Users) and a doubled backslash
collapsed to one. Only the delimiter and the backslash itself are
escapes now; every other backslash is a literal character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:42:29 +05:30
Clintchiz 63316111cb feat(examples): worked example for apis blocks 2026-08-20 08:36:03 +05:30
ClintchizandClaude Opus 5 0ed8351828 test: restore executed and real-tsc coverage lost when the old api-block tests were deleted
Fix round 1: the deleted api-block-*.test.ts files were not fully superseded
by the apis-* siblings as claimed. Ports back, using apis {} fixtures:
- brace-inside-a-string-literal response-section scanner regression test
- type erasure of response/error bodies before browser emission
- client-side response-error-not-swallowed / transport-failure-fallback,
  executed via dynamic import of a generated browser module
- the full SSR execution suite: response payload binding, error section
  status/message/data binding, {#each} failure propagation, all executed
  via dynamic import + a real load/api call chain (not string checks)
- the four real-tsc enforcement tests (matching/wrong-type/extra-field/
  missing-field), plus the B1 cross-page collision guard and the B6
  export-for-noUnusedLocals guard

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:14:16 +05:30
ClintchizandClaude Opus 5 890d6106b3 feat: replace the ssr/client data blocks with apis blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:59:37 +05:30
ClintchizandClaude Opus 5 de99a2c2e0 feat(cli): assert types for every api block with declared fields
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:44:58 +05:30
ClintchizandClaude Opus 5 442c058d0c feat(compiler): support the three api render-binding forms
- Parse api="name", api="name()", and api="name({ ... })" render bindings
  for apis {} (mode "any") blocks, mirroring @click="fn()" syntax.
- Render-bind by calling Task 4's generated server `api` object directly
  (api.<name>(args)) rather than re-implementing the fetch/response
  transport, spliced into the SSR template via the existing loop/expression
  sentinel mechanism so the call runs inside the async render function with
  await support.
- A block that is both render-bound and called from code runs twice by
  design (no dedup); pinned with a test.
- Fix packages/syntax's attribute-value lexer (readQuoted) to honor
  backslash-escaped quotes, needed so an api="..." call expression can
  itself contain a quoted string/object literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:38:32 +05:30
Clintchiz a08dfa5322 fix(compiler): reject dynamic api access in client functions
Usage-driven emission can only see api.<name> calls. api["name"]()
or passing api to a helper is invisible to it, silently drops the
block from the browser bundle, and fails at runtime instead of build
time. Detect that dynamic/indirect use (masking strings and comments
first, reusing the tokenizer's skipLiteralOrComment) and refuse to
compile instead, naming the offending function.
2026-08-20 07:25:02 +05:30
Clintchiz 712a6d3d8c feat(compiler): emit browser api bindings only where the client calls them 2026-08-20 07:19:55 +05:30
Clintchiz 9dec811069 fix(compiler): route the generated api object through the real buildApiRequest
Root-cause fix for the fix-round-1 review: the inlined query/body
assembly in __wrnexusCallApi was a third, unguarded copy of
buildApiRequest's rules. Restore the import of buildApiRequest from
@wrnexus/core in the generated module and delete the inline copy.

The four api-block-ssr.test.ts tests (and three in compiler.test.ts)
that dynamically import a generated module from an OS tmpdir were
failing against a stale globally-installed @wrnexus/core (v0.8.8,
predates buildApiRequest) because that tmpdir has no node_modules of
its own and bare-specifier resolution walked out of the workspace.
Fixed at the source: symlink the workspace @wrnexus/core into each
tmpdir root before the dynamic import, the same way every in-repo
package already resolves it.
2026-08-20 07:11:53 +05:30
Clintchiz 847b7010d1 feat(compiler): emit the server-side api object
Server module now declares `const api = { ... }` for apis {} blocks in
mode "any", dispatching in-process via requireRequestContext + the
existing __wrnexusCallApi transport helper. The try wraps only the
transport call; the response body runs after it, outside the try, so
a bug in the author's response code surfaces rather than being
mistaken for a request failure. A block with no error {} section
rethrows instead of resolving undefined.

Also closes the pageCtx.__wrnexusCallApi wiring gap in
dev-server/runtime.ts: it now forwards input through to
callApiFromContext instead of dropping it.
2026-08-20 07:03:11 +05:30
ClintchizandClaude Opus 5 1dbe16dc93 test(core,csr): replace text-substring agreement check with a behavioural one
The old assertions only searched REACTIVE_RUNTIME for substrings; they never
touched buildApiRequest and were not anchored to the content-type line they
claimed to guard, so they could not detect drift on either side. Replace
with a fixture-driven test that runs both implementations on the same
(path, method, input) cases and compares the actual request they produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:47:20 +05:30
ClintchizandClaude Opus 5 f32b33e3b6 feat(core): share API request assembly between both transports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:43:42 +05:30
Clintchiz 768074ac0a fix(dev-server): route server.fn() RPC to a handler in production
server.fn() posts to POST /__wrnexus/rpc. Dev intercepts that path before
handlers.fetch and routes it to a createRpcHandler instance built from
loadWrnServerModule; createProductionServer/createProductionHandlers had no
such route, so the request fell through to the internal-caller-gated
inter-app service RPC and 404'd.

Add resolveProdServerFunctions(), a synchronous equivalent of dev's resolve
that searches the already-statically-imported ProdManifest components/pages/
layouts for __wrnexusServerFunctions + __wrnexusRpcManifest, and wire it into
createProductionHandlers with the same validateCsrf + withServerFnRequestContext
wrapping dev uses. Move those two helpers into a new rpc-shared.ts so prod.ts
can use them without a circular import through index.ts.

Add packages/dev-server/test/prod-server-fn-rpc.test.ts covering a successful
call, CSRF rejection, and clean 404s for an unknown component/function.
2026-08-20 06:41:32 +05:30
ClintchizandClaude Opus 5 c7e40154ca fix(core): establish request context at every server-code entry point
Wraps the three additional entry points where user server code runs
outside fetchHandler's own context wrap:
- the server-function RPC path (/__wrnexus/rpc) intercepted before
  handlers.fetch in the dev server (index.ts) - what server.fn() travels
- the service RPC path (isRpcPath) inside fetchHandler, which runs
  implement()/implementStream() service code before ctx existed
- the HMR-sync handler, which runs real load blocks/actions via dispatch()

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:55:18 +05:30
ClintchizandClaude Opus 5 680ea73975 feat(core): carry the request context in an AsyncLocalStorage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:46:24 +05:30
ClintchizandClaude Opus 5 953b1cd692 fix(syntax): reject bare apis-container bodies and cross-mode duplicate api names
Bare bodies inside apis {} silently discarded their text with no error,
producing a do-nothing block. They now throw a ParseError naming the entry
and pointing at the response {} section. Duplicate-name detection for
dataApis moved from an incremental, order-dependent check (only saw prior
entries in the array) to a single post-parse pass over the whole ast.dataApis,
so it catches cross-mode duplicates (apis {} vs ssr { api }) regardless of
declaration order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:33:05 +05:30
ClintchizandClaude Opus 5 87a00de5f3 feat(syntax): parse the apis container block
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:28:23 +05:30
ClintchizandClaude Opus 5 d069f5ddd7 fix: correct feature-report prose, restore update test coverage, warn on sub-0.8.0 upgrades
- generate-complete-framework-report.mjs no longer claims legacyEmit/
  legacyEventProps/legacyComponentDiscovery/stringLayouts/
  functions.legacyDefaultRuntime are usable compatibility flags; they were
  removed before the first public release and a config setting them is now
  rejected. Regenerated docs/WRNEXUS-COMPLETE-FEATURE-REPORT.md.
- Restored the update.test.ts coverage lost in the sub-0.8.0 migration
  cleanup: a 0.8.x fixture now asserts refreshFrameworkFiles' still-live
  behaviour (public/llms.txt and CLAUDE.md creation) and that
  pkg.wrnexus.version stays at its old value after an unverified update.
  The .gitignore refresh and build/start/production script backfill were
  themselves removed as part of dropping the sub-0.8.0 migrations that
  implemented them, so there is nothing left to cover for those two.
- updateApp now logs a clear warning when the detected project version is
  below 0.8.0, naming the version and stating that automated migration from
  below 0.8.0 is no longer supported, without failing the command (a
  marker-less app, like examples/basic-app, is benign and must still
  upgrade cleanly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:19:23 +05:30
ClintchizandClaude Opus 5 abebbcf8af chore: regenerate baselines after the legacy cleanup
- regenerate docs/public-api-0.8.json (removals only: CompatibilityPolicy,
  CompatibilityReport, CURRENT_COMPATIBILITY_DATE, CURRENT_FRAMEWORK_BEHAVIOUR,
  isCompatibilityDate, resolveCompatibility from @wrnexus/styles)
- regenerate docs/WRNEXUS-COMPLETE-FEATURE-REPORT.md
- remove the deleted 'wrnexus compatibility' command row and its config-pinning
  sentence from packages/cli/README.md
- drop compatibilityDate/frameworkBehaviour example fields from docs/GUIDE.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:04:59 +05:30
ClintchizandClaude Opus 5 5720f93db1 test(auth): restore plugin engine-hook coverage via invokeAuthHandler
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:43:38 +05:30
ClintchizandClaude Opus 5 7f5e1bc3cf refactor: remove the deprecated auth options
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:38:08 +05:30
ClintchizandClaude Opus 5 97d60d0ba8 refactor: drop the deprecated compiler re-export shims
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:33:48 +05:30
ClintchizandClaude Opus 5 e7e7b58160 chore: drop update migrations below 0.8.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:28:33 +05:30
ClintchizandClaude Opus 5 e09a35b4bd fix(store): give the fix-round-2 regression test explicit generics
The single-branch 'shared'-only action fixture failed to infer through
defineStore's actions generic, typing store.increment as never and
failing bun run typecheck (TS2349) even though bun test passed.
Explicit type arguments fix the inference without weakening the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:15:35 +05:30
Clintchiz 5c8f3ede54 refactor: delete the compatibility config surface 2026-08-20 01:11:35 +05:30
ClintchizandClaude Opus 5 d19595c7ba fix(store): drop unreachable legacy StoreRuntime fallback
Fix round 2 for task 1: store-codegen.ts stopped emitting runtime:
"legacy" actions in round 1, making the StoreRuntime variant and its
resolution fallback dead. Narrows StoreRuntime to three variants and
adds a regression test for the remaining options.runtime -> shared
fallback chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:58:26 +05:30
ClintchizandClaude Opus 5 b7796b103a fix(typecheck): drop legacy FunctionRuntime branches in contracts/index
Fix round 1 for task 1: packages/typecheck also branched on the
removed legacy runtime (componentContract's exclusion filter and the
runtime-namespace loop). Removes both, adds a regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:51:21 +05:30
ClintchizandClaude Opus 5 ec63090006 refactor: replace the legacy function runtime with shared
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:13:05 +05:30
ClintchizandClaude Opus 5 224af8fd96 docs: four implementation plans for the cleanup, apis block, migration, and editor work
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:05:31 +05:30
ClintchizandClaude Opus 5 68cc75d0b0 docs: fold the auth deprecations into the cleanup spec as a firm scope
They are our own superseded options, not a stale dependency. The recommended
form is already what the showcase example uses; the blast radius is three
test files inside packages/auth, and the rpId/origin options are already
ignored at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:55:50 +05:30
ClintchizandClaude Opus 5 43652c14af docs: specs for apis blocks, migration, and editor tooling
Three specs completing the set, each depending on the one before it:

- apis {}: one container, mode-less declarations, api.<name>() callable
  anywhere with build-time dispatch, AsyncLocalStorage for server context,
  usage-driven emission, three render-binding forms. Replaces the ssr {} /
  client {} data blocks and the untypeable with($data) legacy body.
- update: migrations to the new syntax. The legacy bare-body rewrite is
  deliberately manual -- which free identifiers are payload fields is not
  knowable from the source, so an automatic guess would compile and be wrong.
- editor tooling: grammar, completions for api. and the api= attribute,
  diagnostics for removed constructs, and resolving by observation whether
  generated type errors surface inline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:52:31 +05:30
ClintchizandClaude Opus 5 e40d8319a6 docs: spec for the legacy, deprecated, and unused-config cleanup
All seven compatibility keys are dead configuration: traced every reference
and none is read by any compiler, codegen, or runtime code. They are written
into every generated config and ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:47:22 +05:30
149 changed files with 10315 additions and 3448 deletions
+25 -4
View File
@@ -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.46",
"version": "0.8.47",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -337,7 +356,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.41",
"version": "0.8.42",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -642,7 +661,7 @@
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.8.20",
"version": "0.8.21",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
@@ -1406,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=="],
-2
View File
@@ -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)
+13 -13
View File
@@ -10,7 +10,7 @@ Generated for workspace version **0.8.8** from the checked-out source and genera
- Architecture: compiler-driven, SSR-first, Bun-native/full-stack, with Node-friendly selected tooling.
- Static routes retain the zero-framework-JavaScript goal; hydration is selective and islands are loaded only where declared.
- Current headline features include typed callable API blocks, reactive `if`/`each` control blocks, runtime-scoped state and functions, typed outputs, stores, React islands, HTML-aware editor tooling, package/plugin discovery, generated contracts, security gates, and multi-app RPC.
- Audited public API baseline: **48 packages / 2741 exported symbols** across root and subpath exports.
- Audited public API baseline: **48 packages / 2735 exported symbols** across root and subpath exports.
- Audited packaged `.wrn` sources: **142**; generated UI reference: **102 components**.
## 2. What a `.wrn` file is
@@ -119,11 +119,11 @@ Legacy API binding remains valid: `ssr { api users GET /api/users { return users
| Legacy/earlier approach | Current approach | Compatibility/status |
| ------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Compiler-owned/internal parsing imports | Canonical `@wrnexus/syntax` lexer/parser/AST/diagnostics | Compiler re-exports remain for compatibility; direct internals are deprecated. |
| Implicit component discovery everywhere | Explicit imports and generated contracts | Upgraded apps can retain `legacyComponentDiscovery`; unresolved symbols are reported rather than guessed. |
| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts remain behind compatibility configuration. |
| Implicit component discovery everywhere | Explicit imports and generated contracts | Unresolved symbols are reported rather than guessed; there is no compatibility opt-out. |
| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts are no longer supported; the config key that toggled them was removed. |
| `@event changed = function` in props | `outputs { changed(payload: Type) }` | v0.6 migration converts declarations; ambiguous payloads become `unknown`. |
| `$emit("changed", value)` and `event.detail` | `output.changed(value)` and direct `payload` | Static cases auto-migrated; dynamic emit names require review. |
| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; compatibility default can preserve ambiguous behavior. |
| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; the legacy default runtime that preserved ambiguous behavior was removed. |
| Manually copied CAPTCHA JS/script tags | Package-discovered client runtime/assets | v0.4 removes tags and archives old assets under `.wrnexus/legacy-assets/0.4.0`. |
| Package components/routes/assets wired manually | Automatic package/plugin discovery and contribution registry | Current CLI/build/dev server inspect and consume contributions. |
| Only scalar/quoted dynamic props | Native arrays/objects and JSX-style unquoted expressions | Quoted expression attributes remain supported. |
@@ -137,7 +137,7 @@ Legacy API binding remains valid: `ssr { api users GET /api/users { return users
| Per-component/global style placement inconsistencies | `style {}` promoted to document head | Current pipeline supports CSP, HMR and CSR navigation. |
| Manually maintained API/component knowledge | Generated public API and component references plus validation gates | `check:public-api`, generated-type checks, UI visual contract and package audits detect drift. |
Compatibility flags visible in generated/upgraded config include `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime`. New projects default legacy flags off; migration-created configs may enable them to preserve behavior until source modernization is complete.
`legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime` (and the other pre-1.0 compatibility keys) were removed before the first public release; a config that still sets any of them is rejected by validation rather than honored.
## 7. Diagnostics, security, and correctness guarantees
@@ -174,11 +174,11 @@ Run `bun run check:production` for the complete production gate. Its chain cover
| `@wrnexus/benchmark` | 0.8.8 | Deterministic benchmark runner and performance regression budgets for WRNexusJS. |
| `@wrnexus/cache` | 0.8.8 | Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS. |
| `@wrnexus/captcha` | 0.8.11 | First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI. |
| `@wrnexus/cli` | 0.8.45 | — |
| `@wrnexus/compiler` | 0.8.13 | — |
| `@wrnexus/cli` | 0.8.46 | — |
| `@wrnexus/compiler` | 0.8.14 | — |
| `@wrnexus/content` | 0.8.9 | Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS. |
| `@wrnexus/core` | 0.8.9 | — |
| `@wrnexus/csr` | 0.8.24 | — |
| `@wrnexus/core` | 0.8.10 | — |
| `@wrnexus/csr` | 0.8.25 | — |
| `@wrnexus/db` | 0.8.16 | Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers. |
| `@wrnexus/dev-server` | 0.8.41 | — |
| `@wrnexus/dev-toolbar` | 0.8.13 | — |
@@ -209,13 +209,13 @@ Run `bun run check:production` for the complete production gate. Its chain cover
| `@wrnexus/ssr` | 0.8.9 | — |
| `@wrnexus/store` | 0.8.8 | — |
| `@wrnexus/styles` | 0.8.15 | — |
| `@wrnexus/syntax` | 0.8.9 | — |
| `@wrnexus/syntax` | 0.8.10 | — |
| `@wrnexus/test` | 0.8.9 | — |
| `@wrnexus/tracking` | 0.8.8 | — |
| `@wrnexus/typecheck` | 0.8.10 | — |
| `@wrnexus/ui` | 0.8.20 | — |
| `@wrnexus/uploader` | 0.8.10 | Secure upload drivers, policies, client runtime, helper functions, and reusable upload components. |
| `@wrnexus/validation` | 0.8.10 | Shared server/browser schemas, validation helpers, form runtime, and reusable error components. |
| `@wrnexus/validation` | 0.8.11 | Shared server/browser schemas, validation helpers, form runtime, and reusable error components. |
## Appendix B — complete audited public export inventory
@@ -456,9 +456,9 @@ Run `bun run check:production` for the complete production gate. Its chain cover
- **./server:** `createRequestStoreContainer`
- **./types:** `PersistenceStorage`, `StoreActionContext`, `StoreActionDefinition`, `StoreCombinedState`, `StoreDefinition`, `StoreFunction`, `StoreInstance`, `StoreInstanceCore`, `StoreKind`, `StoreLifecycleContext`, `StoreMutation`, `StorePersistenceConfig`, `StoreRuntime`
### `@wrnexus/styles` (82 symbols)
### `@wrnexus/styles` (76 symbols)
- **.:** `ACCENT_COOKIE`, `AppConfig`, `BrowserCookieApi`, `BrowserCookieOptions`, `BrowserCookiePreference`, `BrowserCookiesConfig`, `BuildConfig`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, `CompatibilityReport`, `ConfigIssue`, `ContrastResult`, `CssPerformanceAuditIssue`, `CssTokenAudit`, `CustomThemePalette`, `DEFAULT_THEMES`, `DevToolbarConfig`, `ExperimentalConfig`, `ExplainedConfig`, `FontConfig`, `FontDisplay`, `GoogleFont`, `LocalFontFace`, `MobileConfig`, `Mode`, `NavigationConfig`, `ObservabilityConfig`, `PerformanceConfig`, `PwaConfig`, `ResolvedConfigLayers`, `ResolvedTheme`, `StyleProcessContext`, `StyleSource`, `StylesConfig`, `StylesMode`, `THEME_COOKIE`, `THEME_CSS_HREF`, `THEME_CSS_PREFIX`, `THEME_JS_HREF`, `THEME_PALETTES`, `THEME_PALETTE_NAMES`, `TenancyConfig`, `ThemeAccentConfig`, `ThemeConfig`, `ThemeCookieConfig`, `ThemePaletteName`, `ThemeSemanticColor`, `ThemeToken`, `ThemeTokens`, `activeThemeCssHref`, `auditCssPerformance`, `auditWrnTokens`, `bundleCss`, `contrast`, `defineConfig`, `defineThemeTokens`, `explainAppConfig`, `findStyleEntry`, `fontCspSources`, `headToString`, `isCompatibilityDate`, `loadAppConfig`, `loadEnv`, `loadRawConfig`, `normalizeStyleSources`, `renderActiveThemeCss`, `renderFontHead`, `renderProductionFontHead`, `renderStyles`, `renderThemeCss`, `renderThemeRuntime`, `resolveAccentName`, `resolveBrowserCookieOptions`, `resolveCompatibility`, `resolveConfigLayers`, `resolveProfile`, `resolveThemeConfig`, `resolveThemeName`, `tailwindSourceDirectives`, `themeVar`, `validateAppConfig`
- **.:** `ACCENT_COOKIE`, `AppConfig`, `BrowserCookieApi`, `BrowserCookieOptions`, `BrowserCookiePreference`, `BrowserCookiesConfig`, `BuildConfig`, `ConfigIssue`, `ContrastResult`, `CssPerformanceAuditIssue`, `CssTokenAudit`, `CustomThemePalette`, `DEFAULT_THEMES`, `DevToolbarConfig`, `ExperimentalConfig`, `ExplainedConfig`, `FontConfig`, `FontDisplay`, `GoogleFont`, `LocalFontFace`, `MobileConfig`, `Mode`, `NavigationConfig`, `ObservabilityConfig`, `PerformanceConfig`, `PwaConfig`, `ResolvedConfigLayers`, `ResolvedTheme`, `StyleProcessContext`, `StyleSource`, `StylesConfig`, `StylesMode`, `THEME_COOKIE`, `THEME_CSS_HREF`, `THEME_CSS_PREFIX`, `THEME_JS_HREF`, `THEME_PALETTES`, `THEME_PALETTE_NAMES`, `TenancyConfig`, `ThemeAccentConfig`, `ThemeConfig`, `ThemeCookieConfig`, `ThemePaletteName`, `ThemeSemanticColor`, `ThemeToken`, `ThemeTokens`, `activeThemeCssHref`, `auditCssPerformance`, `auditWrnTokens`, `bundleCss`, `contrast`, `defineConfig`, `defineThemeTokens`, `explainAppConfig`, `findStyleEntry`, `fontCspSources`, `headToString`, `loadAppConfig`, `loadEnv`, `loadRawConfig`, `normalizeStyleSources`, `renderActiveThemeCss`, `renderFontHead`, `renderProductionFontHead`, `renderStyles`, `renderThemeCss`, `renderThemeRuntime`, `resolveAccentName`, `resolveBrowserCookieOptions`, `resolveConfigLayers`, `resolveProfile`, `resolveThemeConfig`, `resolveThemeName`, `tailwindSourceDirectives`, `themeVar`, `validateAppConfig`
### `@wrnexus/syntax` (141 symbols)
+13 -8
View File
@@ -1084,6 +1084,7 @@
"BackoffStrategy",
"Bucket",
"BudgetViolation",
"BuiltApiRequest",
"Bulkhead",
"BulkheadOptions",
"CSRF_COOKIE",
@@ -1204,6 +1205,7 @@
"UploadScanner",
"assertTenantAccess",
"bridgeRealtime",
"buildApiRequest",
"cacheControl",
"checkPerformanceBudgets",
"collectUploads",
@@ -1229,6 +1231,7 @@
"escapeHtml",
"etag",
"executionContextFromHttp",
"getRequestContext",
"getUser",
"hashPassword",
"isRoomDefinition",
@@ -1260,9 +1263,11 @@
"requestId",
"requestLogger",
"requireAuth",
"requireRequestContext",
"requireTenant",
"resilientCall",
"resolveRequestUrl",
"runWithRequestContext",
"sanitizeFilename",
"saveUpload",
"saveUploadSecure",
@@ -1465,6 +1470,7 @@
"startServer",
"toRequest",
"validateRpcCsrf",
"withServerFnRequestContext",
"writeResponse"
],
"./serve-entry": []
@@ -1834,7 +1840,7 @@
"Position",
"Range",
"TextDocument",
"WRN_COMPLETIONS",
"WRN_KEYWORDS",
"WorkspaceCompletionItem",
"clearWorkspaceIndexCache",
"completionItems",
@@ -1855,7 +1861,11 @@
"workspaceCompletionItems",
"workspaceSymbolLocations"
],
"./server": []
"./server": [
"ApiCallCompletionItem",
"apiCallCompletions",
"apiCallHover"
]
},
"@wrnexus/mcp": {
".": [
@@ -2633,10 +2643,6 @@
"BrowserCookiePreference",
"BrowserCookiesConfig",
"BuildConfig",
"CURRENT_COMPATIBILITY_DATE",
"CURRENT_FRAMEWORK_BEHAVIOUR",
"CompatibilityPolicy",
"CompatibilityReport",
"ConfigIssue",
"ContrastResult",
"CssPerformanceAuditIssue",
@@ -2687,7 +2693,6 @@
"findStyleEntry",
"fontCspSources",
"headToString",
"isCompatibilityDate",
"loadAppConfig",
"loadEnv",
"loadRawConfig",
@@ -2700,7 +2705,6 @@
"renderThemeRuntime",
"resolveAccentName",
"resolveBrowserCookieOptions",
"resolveCompatibility",
"resolveConfigLayers",
"resolveProfile",
"resolveThemeConfig",
@@ -2788,6 +2792,7 @@
"parseStructuredImports",
"positionAt",
"runtimeTypeOf",
"skipLiteralOrComment",
"sliceSource",
"stripRuntimeFunctionModifiers",
"supportsSyntaxFeature",
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.
@@ -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,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.
+294 -217
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
// WRN editor compiler source hash: 2987e8e9d91b793818a195395e740d35d0d5c2a320ff1c3a5ae77363850c7b8e
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -600,7 +600,7 @@ function selectedBrowserImports(ast, functions) {
.filter((entry) => entry !== null);
}
function browserModuleRequired(ast) {
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
}
function _functionEntry(ast, fn, availableFunctions) {
@@ -713,15 +713,111 @@ function _functionEntry(ast, fn, availableFunctions) {
}`;
}
/**
* Client-mode api blocks become members of an `api` object in client scope.
* Blank out string/template literals and comments in a raw JS body, preserving
* length and newlines, so a scanner walking the result never mistakes text
* inside a string or comment for real code. Reuses the tokenizer's
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
* reimplementing them a second hand-rolled scanner is how apostrophes in
* prose used to swallow braces elsewhere in this codebase.
*/
function maskStringsAndComments(src) {
let out = "";
let i = 0;
let atLineStart = true;
while (i < src.length) {
const c = src[i];
if (c === "\n") {
out += c;
atLineStart = true;
i++;
continue;
}
const skipped = (0, syntax_1.skipLiteralOrComment)(src, i, atLineStart);
if (skipped !== null) {
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
i = skipped;
atLineStart = false;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r")
atLineStart = false;
out += c;
i++;
}
return out;
}
/**
* Block names the page's client functions actually call.
*
* A block's response and error bodies are page code. Emitting one the browser
* never calls would ship a server-only transform to every visitor and grow the
* bundle for nothing.
*/
function clientCalledApiNames(ast) {
const called = new Set();
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
const masked = maskStringsAndComments(fn.body);
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
called.add(match[1]);
}
}
return called;
}
/**
* Refuse to compile a client/shared function whose body references `api` in
* any form other than `api.<identifier>` e.g. `api["searchUsers"]()`, or
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
* above) can only see `api.<identifier>` calls; a dynamic or indirect
* reference is invisible to it, so the referenced block would be silently
* dropped from the browser bundle and the call would fail at runtime with
* "api.<name> is not a function". That failure direction is worse than a
* loud compile error, so it is caught here instead.
*
* Strings and comments are masked out first so `api` appearing in prose or in
* a quoted value never trips this check, and every `api.<identifier>` access
* is stripped before the standalone-word scan so a real, well-formed call
* never does either.
*/
function assertNoDynamicApiAccess(ast) {
// Only pages with a sectioned api block have anything at stake here: those
// blocks are emitted solely because usage detection saw `api.<name>`, so a
// dynamic reference this scan can't see is the one that silently drops a
// block from the bundle. A page with no api blocks at all may still declare
// an ordinary `state api` (see the B5 regression test) where a bare "api"
// identifier is just that state, not a missed block reference.
if (!ast.dataApis.some((block) => block.sections))
return;
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
const masked = maskStringsAndComments(fn.body);
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) => match.replace(/[^\n]/g, " "));
if (/\bapi\b/.test(withoutCalls)) {
throw new Error(`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`);
}
}
}
/**
* A block is emitted into the browser module when it declares typed sections
* and a client function actually calls it. `hasClientApi` below must use this
* exact predicate so the `api` reserved-binding exclusion and the emitted
* object can never disagree.
*/
function isClientEmittedApiBlock(block, called) {
return Boolean(block.sections) && called.has(block.name);
}
/**
* Client-mode and client-called any-mode api blocks become members of an
* `api` object in client scope.
*
* Only the response and error bodies are emitted; the declared field types are
* type-only and are consumed by the types generator instead. Anything
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
*/
function apiBindings(ast) {
const called = clientCalledApiNames(ast);
const members = ast.dataApis
.filter((block) => block.mode === "client" && block.sections)
.filter((block) => isClientEmittedApiBlock(block, called))
.map((block) => {
const sections = block.sections;
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
@@ -734,7 +830,8 @@ function apiBindings(ast) {
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
function generateBrowserModule(ast) {
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
assertNoDynamicApiAccess(ast);
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
const functionNames = functions.map((fn) => fn.name);
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
const selectedImports = selectedBrowserImports(ast, functions);
@@ -745,7 +842,7 @@ function generateBrowserModule(ast) {
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const hasClientApi = ast.dataApis.some((block) => isClientEmittedApiBlock(block, clientCalledApiNames(ast)));
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
@@ -844,8 +941,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.generate = generate;
exports.parseForExpr = parseForExpr;
const node_buffer_1 = require("node:buffer");
const parser_ts_1 = require("./parser.js");
const types_ts_1 = require("./types.js");
const syntax_1 = require("@wrnexus/syntax");
const store_codegen_ts_1 = require("./store-codegen.js");
const analysis_ts_1 = require("./analysis.js");
@@ -1245,7 +1340,7 @@ function renderLoopBody(node) {
inner +
escLit("</div>"));
}
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return escLit(`<${node.tag}`) + attrs + escLit(">");
}
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
@@ -1288,32 +1383,6 @@ function compileIfExpr(node) {
}
return "${" + expr + "}";
}
/**
* Collect every server-control expression in a view (recursively): `{#each}` list
* expressions and `{#if}` conditions. Used to wrn up raw SSR data consts.
*/
function collectControlExprs(nodes, out = []) {
for (const node of nodes) {
if (node.type === "text")
continue;
if (node.type === "each") {
out.push(node.list);
collectControlExprs(node.body, out);
collectControlExprs(node.empty, out);
}
else if (node.type === "if") {
for (const b of node.branches) {
if (b.cond)
out.push(b.cond);
collectControlExprs(b.body, out);
}
}
else if (node.type === "element") {
collectControlExprs(node.children, out);
}
}
return out;
}
function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) {
if (node.type === "text")
return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking
@@ -1408,31 +1477,33 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
if (isComponentTag(node.tag)) {
return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
}
const apiName = attrValue(node.attrs, "api");
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
if (apiName && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${apiName}"`);
const apiAttr = attrValue(node.attrs, "api");
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
if (apiAttr && !parsedApi) {
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
}
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
if (parsedApi && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
const ssrText = attrValue(node.attrs, "ssrText");
const csrGet = attrValue(node.attrs, "csrGet");
const csrText = attrValue(node.attrs, "csrText");
const csrId = apiBinding?.mode === "client"
? csrMarker(csrBindings, renderBinding(apiBinding))
: csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
const csrId = csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
// Void elements (<br>, <img>, …) have no closing tag and no children.
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
}
const inner = apiBinding?.mode === "ssr"
? ssrMarker(ssrBindings, renderBinding(apiBinding))
const inner = apiBinding
? apiCallMarker(loops, parsedApi.name, parsedApi.args)
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
@@ -1505,6 +1576,33 @@ function renderNestedComponentInvocation(node, ctx) {
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
`${attrs}>${inner}</div>`);
}
/**
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
* so no new escaping or attribute-naming rules are introduced. The argument
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
* the argument expression (object literals, arrays, strings) are carried
* through untouched rather than truncated at the first `)`.
*/
function parseApiBinding(value) {
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
if (!match)
return null;
return { name: match[1], args: (match[2] ?? "").trim() };
}
/**
* Emit a render-time call into the server `api` object (Task 4's generated
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
* that the loop/expression-splicing mechanism swaps for the real `${}` code.
* This calls the same server `api.<name>()` member a `load`/action block would
* call -- it does not reimplement fetch/response handling -- so a block that is
* both render-bound and called from code runs its own call each time (no
* dedup is attempted; see apis-render-binding.test.ts).
*/
function apiCallMarker(loops, name, args) {
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
return `\x00WRNEACH${loops.length - 1}\x00`;
}
function ssrMarker(bindings, binding) {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
@@ -1515,15 +1613,6 @@ function csrMarker(bindings, binding) {
bindings.push({ id, ...binding });
return id;
}
function renderBinding(binding) {
return {
method: binding.method,
path: binding.path,
body: binding.body,
helpers: binding.helpers,
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
};
}
function hasClientBehavior(nodes) {
return nodes.some((node) => {
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
@@ -1561,17 +1650,6 @@ function dataBody(source) {
return "return undefined;";
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
}
function modeHelpers(ast, mode, sharedHelpers) {
return [
sharedHelpers,
...ast.modeFunctions
.filter((block) => block.mode === mode)
.map((block) => block.body.trim())
.filter(Boolean),
]
.filter(Boolean)
.join("\n\n");
}
function apiBindingMap(ast, sharedHelpers) {
const bindings = new Map();
for (const block of ast.dataApis) {
@@ -1593,11 +1671,42 @@ function apiBindingMap(ast, sharedHelpers) {
// legacy blocks and sectioned blocks without `error` keep failures
// propagating exactly as before.
...(errorSection ? { errorBody: errorSection } : {}),
helpers: modeHelpers(ast, block.mode, sharedHelpers),
helpers: sharedHelpers,
});
}
return bindings;
}
/**
* Server-side `api` object.
*
* The transport dispatches in-process, so a call from a load block or an action
* costs a function call rather than a network round trip. The request context
* comes from AsyncLocalStorage because `ctx` is not in scope everywhere server
* code runs.
*/
function serverApiBindings(ast) {
const members = ast.dataApis
.filter((block) => block.mode === "any")
.map((block) => {
const sections = block.sections;
const response = sections.response.trim() || "return data;";
const error = sections.error.trim();
const failure = error
? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}`
: `throw err;`;
return ` ${JSON.stringify(block.name)}: async (input?: unknown) => {
const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext;
let data: any;
try {
data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input);
} catch (err) {
${failure}
}
${response}
}`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n};` : "";
}
function ssrRuntimeSource() {
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
@@ -1605,7 +1714,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
}
type __WrnexusContext = import("@wrnexus/core").Context & {
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
localStorage?: unknown;
};
@@ -1652,13 +1761,27 @@ function __wrnexusPropAttr(
);
}
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
async function __wrnexusCallApi(
path: string,
method: string,
ctx: __WrnexusContext,
input?: unknown,
): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method);
return await ctx.__wrnexusCallApi(path, method, input);
}
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
const built = __wrnexusBuildApiRequest(path, method, input as Record<string, unknown> | undefined);
const url = new URL(built.url, ctx.req.url);
const headers = new Headers(ctx.req.headers);
if (built.contentType) headers.set("content-type", built.contentType);
const res = await fetch(
new Request(url, {
method,
headers,
...(built.body === undefined ? {} : { body: built.body }),
}),
);
const type = res.headers.get("content-type") || "";
if (!res.ok) {
const data = type.includes("application/json")
@@ -1900,9 +2023,7 @@ function hydrationAttribute(ast) {
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`;
}
function targetFunctions(ast, target) {
const runtimes = target === "browser"
? ["legacy", "client", "shared"]
: ["legacy", "server", "shared"];
const runtimes = target === "browser" ? ["client", "shared"] : ["server", "shared"];
return ast.functions
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, [...runtimes]))
.map((body) => body.trim())
@@ -2003,6 +2124,10 @@ function generateInner(ast) {
}
if (ast.imports.length > 0)
out.push(generatedImports(ast).join("\n"));
const hasServerApis = ast.dataApis.some((block) => block.mode === "any");
if (hasServerApis) {
out.push(`import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`);
}
const ssrBindings = [];
const csrBindings = [];
const helpers = targetFunctions(ast, "server");
@@ -2126,26 +2251,18 @@ function generateInner(ast) {
staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code);
}
}
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
const loopConsts = [];
if (loops.length > 0) {
const lists = collectControlExprs(ast.view);
for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr")
continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
continue;
const errorBodyProp = binding.errorBody
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
: "";
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
}
}
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
if (needsSsrRuntime) {
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
if (needsRuntimeHelpers) {
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
out.push(ssrRuntimeSource());
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
}
if (hasServerApis) {
out.push(serverApiBindings(ast));
}
if (needsSsrRuntime) {
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
${storeDeclarations}
@@ -2409,7 +2526,7 @@ function escLit(s) {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast) {
const functions = (0, types_ts_1.eraseFunctionTypes)(targetFunctions(ast, "browser"));
const functions = (0, syntax_1.eraseFunctionTypes)(targetFunctions(ast, "browser"));
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
const lifecycle = {
@@ -2621,7 +2738,7 @@ function renderClientControlTemplate(nodes) {
return children;
if (componentTag)
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
return `<${node.tag}${attrs}>`;
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
};
@@ -2906,7 +3023,7 @@ function renderComponentNode(node, ctx) {
`${classReactiveBinding}` +
`${classBindings}` +
`${attrs}`;
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${allAttrs}>`;
}
const inner = node.children.map((child) => renderComponentNode(child, elementContext)).join("");
@@ -2994,7 +3111,7 @@ function generateComponent(ast) {
if (prop.required) {
decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`);
}
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`);
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, syntax_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`);
}
if (!effectiveProps.some((prop) => prop.name === "attrs")) {
decls.push(` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`);
@@ -4059,27 +4176,6 @@ function generateNative(ast) {
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n${ast.imports.join("\n")}\n\n${typeSource}\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
}
},
"packages/compiler/src/parser.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
/** @deprecated Import parser APIs from @wrnexus/syntax. */
__exportStar(require("@wrnexus/syntax/parser"), exports);
},
"packages/compiler/src/runtime-capabilities.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
@@ -4163,7 +4259,7 @@ function stableId(value) {
*/
function remotelyReferencedServerFunctions(ast) {
const browserSources = ast.runtimeFunctions
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
.filter((fn) => ["client", "shared"].includes(fn.runtime))
.map((fn) => fn.body);
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
if (hook !== "serverInit" && body)
@@ -4195,11 +4291,11 @@ function rpcManifest(ast) {
}
function generateServerFunctionsModule(ast) {
const source = ast.functions
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, ["legacy", "server", "shared"]))
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, ["server", "shared"]))
.filter(Boolean)
.join("\n\n");
const names = ast.runtimeFunctions
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
.filter((fn) => ["server", "shared"].includes(fn.runtime))
.map((fn) => fn.name);
const manifest = rpcManifest(ast);
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
@@ -4381,7 +4477,7 @@ function generateStoreBrowserModule(ast) {
.map((entry) => `${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`)
.join(",\n");
const groups = new Map();
for (const fn of ast.runtimeFunctions.filter((entry) => ["client", "shared", "legacy"].includes(entry.runtime))) {
for (const fn of ast.runtimeFunctions.filter((entry) => ["client", "shared"].includes(entry.runtime))) {
const group = groups.get(fn.name) ?? [];
group.push(fn);
groups.set(fn.name, group);
@@ -4496,7 +4592,7 @@ function __create(definition) {
Object.keys(actions).forEach(function (name) { delete actions[name]; });
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
const name = pair[0], candidates = pair[1];
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; });
if (!selected) return;
actions[name] = async function () {
const args = Array.prototype.slice.call(arguments);
@@ -4629,27 +4725,6 @@ function generateTargets(ast) {
};
}
},
"packages/compiler/src/tokenizer.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
__exportStar(require("@wrnexus/syntax/tokenizer"), exports);
},
"packages/compiler/src/type-codegen.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
@@ -4689,7 +4764,7 @@ function generateDeclarations(ast) {
.map((output) => ` ${member(output.name)}(${output.payload ? `${member(output.payload.name)}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`)
.join("\n");
const clientFunctions = ast.runtimeFunctions
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared")
.map((fn) => ` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`)
.join("\n");
const serverFunctions = ast.runtimeFunctions
@@ -4699,33 +4774,13 @@ function generateDeclarations(ast) {
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}ClientFunctions {\n${clientFunctions}\n}\n\nexport interface ${ast.name}ServerCalls {\n${serverFunctions}\n}\n`;
}
},
"packages/compiler/src/types.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
/** @deprecated Import language type utilities from @wrnexus/syntax. */
__exportStar(require("@wrnexus/syntax/types"), exports);
},
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseApiSections = parseApiSections;
exports.hasRequestSection = hasRequestSection;
exports.parseApiEntries = parseApiEntries;
/**
* Parse the sectioned form of an `api` block body.
*
@@ -4864,6 +4919,42 @@ function parseApiSections(source) {
function hasRequestSection(source) {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
}
/**
* Parse the body of a page-level `apis { }` container: a sequence of
* `<name> <METHOD> <path> { ... }` entries with no leading `api` keyword
* (the container supplies it). Each entry's braces are sliced with the
* tokenizer's own `Lexer.readBalancedBraces()`, so the same string- and
* comment-aware rules that protect `parseApiSections` apply here too.
*/
function parseApiEntries(source) {
const entries = [];
const lx = new tokenizer_ts_1.Lexer(source);
while (lx.peek().type !== "eof") {
const nameToken = lx.next();
if (nameToken.type !== "ident") {
throw new tokenizer_ts_1.LexError(`Expected an api entry name at offset ${nameToken.pos}`);
}
const methodToken = lx.next();
if (methodToken.type !== "ident") {
throw new tokenizer_ts_1.LexError(`Expected an HTTP method after "${nameToken.value}"`);
}
const path = lx.readPath();
const entryBody = lx.readBalancedBraces();
const sections = parseApiSections(entryBody);
if (sections === null) {
throw new tokenizer_ts_1.LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`);
}
entries.push({
mode: "any",
name: nameToken.value,
method: methodToken.value.toUpperCase(),
path,
body: "",
sections,
});
}
return entries;
}
},
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
@@ -6161,10 +6252,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.LexError = exports.Lexer = void 0;
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0;
var tokenizer_ts_1 = require("./tokenizer.js");
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } });
Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } });
Object.defineProperty(exports, "skipLiteralOrComment", { enumerable: true, get: function () { return tokenizer_ts_1.skipLiteralOrComment; } });
var formatter_ts_1 = require("./formatter.js");
Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } });
var parser_ts_1 = require("./parser.js");
@@ -6654,7 +6746,6 @@ function parse(source) {
case "client":
case "server": {
const rawMode = kw.value;
const mode = rawMode === "client" ? "client" : "ssr";
lx.next();
if ((rawMode === "client" || rawMode === "server") &&
lx.peek().type === "ident" &&
@@ -6665,52 +6756,15 @@ function parse(source) {
states.push(...(0, v060_ts_1.parseStateDeclarations)(lx.readBalancedBraces(), rawMode));
break;
}
if (mode === "client" && lx.peek().type === "eq") {
if (rawMode === "client" && lx.peek().type === "eq") {
lx.next();
hydrate = expect("string").value;
break;
}
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const member = lx.peek();
if (member.type === "eof") {
throw new ParseError(`Unexpected end of input inside ${mode} block`);
}
if (member.type !== "ident") {
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
}
switch (member.value) {
case "api": {
lx.next();
const name = expect("ident").value;
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
const sections = (0, api_sections_ts_1.parseApiSections)(body);
if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) {
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
}
dataApis.push({
mode,
name,
method,
path,
body: sections ? "" : body,
...(sections ? { sections } : {}),
});
break;
}
case "functions": {
lx.next();
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
break;
}
default:
throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`);
}
if (lx.peek().type === "lbrace") {
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
}
expect("rbrace");
break;
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
}
case "shared": {
lx.next();
@@ -6823,6 +6877,12 @@ function parse(source) {
persist = (0, v060_ts_1.parsePersist)(lx.readBalancedBraces());
break;
}
case "apis": {
lx.next();
const body = lx.readBalancedBraces();
dataApis.push(...(0, api_sections_ts_1.parseApiEntries)(body));
break;
}
default:
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
}
@@ -6894,6 +6954,13 @@ function parse(source) {
};
for (const name of namedLoads.keys())
visitLoad(name);
const seenApiNames = new Set();
for (const block of dataApis) {
if (seenApiNames.has(block.name)) {
throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE");
}
seenApiNames.add(block.name);
}
return {
type: "page",
imports,
@@ -7002,12 +7069,22 @@ function parseHtmlView(src, pos) {
if (quote !== '"' && quote !== "'")
return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote)
// Backslash escapes the delimiter -- so an attribute value such as the
// call expression `api="fn({ a: \"b\" })"` can carry the same quote
// character it's wrapped in -- and escapes itself. Every other backslash
// is a literal one, so a value like "C:\Users\name" survives intact.
let value = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && (src[i + 1] === quote || src[i + 1] === "\\")) {
value += src[i + 1];
i += 2;
continue;
}
value += src[i];
i++;
}
if (i >= src.length)
return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++; // closing quote
return value;
};
@@ -7973,7 +8050,7 @@ function parseRuntimeFunctions(source) {
i++;
continue;
}
let runtime = "legacy";
let runtime = "shared";
if (["client", "server", "shared"].includes(token.word)) {
runtime = token.word;
i = skipTrivia(source, token.end);
+48
View File
@@ -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",
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
// 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);
+242 -145
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env node
// WRN editor language server source hash: cf98947fd66392200bdfb18524a13e8bbb77d4920ba65a4d724e1124152571a3
// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
@@ -32,7 +33,37 @@ var __toESM = (mod, isNodeMode, target) => {
cache.set(mod, to);
return to;
};
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
// node_modules/.bun/typescript@6.0.3/node_modules/typescript/lib/typescript.js
var require_typescript = __commonJS((exports2, module2) => {
@@ -54,10 +85,10 @@ var require_typescript = __commonJS((exports2, module2) => {
var ts = {};
((module3) => {
var __defProp2 = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
var __getOwnPropNames2 = Object.getOwnPropertyNames;
var __hasOwnProp2 = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
var __export2 = (target, all) => {
for (var name in all)
__defProp2(target, name, { get: all[name], enumerable: true });
};
@@ -65,13 +96,13 @@ var require_typescript = __commonJS((exports2, module2) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames2(from))
if (!__hasOwnProp2.call(to, key) && key !== except)
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => (__copyProps, mod);
var __toCommonJS2 = (mod) => (__copyProps, mod);
var typescript_exports = {};
__export(typescript_exports, {
__export2(typescript_exports, {
ANONYMOUS: () => ANONYMOUS,
AccessFlags: () => AccessFlags,
AssertionLevel: () => AssertionLevel,
@@ -2321,7 +2352,7 @@ var require_typescript = __commonJS((exports2, module2) => {
writeFileEnsuringDirectories: () => writeFileEnsuringDirectories,
zipWith: () => zipWith
});
module3.exports = __toCommonJS(typescript_exports);
module3.exports = __toCommonJS2(typescript_exports);
var versionMajorMinor = "6.0";
var version = "6.0.3";
var Comparison = /* @__PURE__ */ ((Comparison3) => {
@@ -4851,8 +4882,8 @@ ${lanes.join(`
return compareValues(left.length, right.length);
}
var VersionRange = class _VersionRange {
constructor(spec2) {
this._alternatives = spec2 ? Debug.checkDefined(parseRange(spec2), "Invalid range spec.") : emptyArray;
constructor(spec3) {
this._alternatives = spec3 ? Debug.checkDefined(parseRange(spec3), "Invalid range spec.") : emptyArray;
}
static tryParse(text) {
const sets = parseRange(text);
@@ -5055,7 +5086,7 @@ ${lanes.join(`
}
var timestamp = nativePerformanceTime ? () => nativePerformanceTime.now() : Date.now;
var ts_performance_exports = {};
__export(ts_performance_exports, {
__export2(ts_performance_exports, {
clearMarks: () => clearMarks,
clearMeasures: () => clearMeasures,
createTimer: () => createTimer,
@@ -21581,19 +21612,19 @@ ${lanes.join(`
if (specs === undefined || specs.length === 0) {
return;
}
return flatMap(specs, (spec2) => spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage]));
return flatMap(specs, (spec3) => spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage]));
}
function isImplicitGlob(lastPathComponent) {
return !/[.*?]/.test(lastPathComponent);
}
function getPatternFromSpec(spec2, basePath, usage) {
const pattern = spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage]);
function getPatternFromSpec(spec3, basePath, usage) {
const pattern = spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage]);
return pattern && `^(?:${pattern})${usage === "exclude" ? "(?:$|/)" : "$"}`;
}
function getSubPatternFromSpec(spec2, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) {
function getSubPatternFromSpec(spec3, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) {
let subpattern = "";
let hasWrittenComponent = false;
const components = getNormalizedPathComponents(spec2, basePath);
const components = getNormalizedPathComponents(spec3, basePath);
const lastComponent = last(components);
if (usage !== "exclude" && lastComponent === "**") {
return;
@@ -40722,8 +40753,8 @@ ${lanes.join(`
const wildcardFiles = arrayFrom(wildcardFileMap.values());
return literalFiles.concat(wildcardFiles, arrayFrom(wildCardJsonFileMap.values()));
}
function isExcludedFile(pathToCheck, spec2, basePath, useCaseSensitiveFileNames2, currentDirectory) {
const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec2;
function isExcludedFile(pathToCheck, spec3, basePath, useCaseSensitiveFileNames2, currentDirectory) {
const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec3;
if (!length(validatedIncludeSpecs) || !length(validatedExcludeSpecs))
return false;
basePath = normalizePath(basePath);
@@ -40745,7 +40776,7 @@ ${lanes.join(`
return lastDotIndex > wildcardIndex;
}
function matchesExclude(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory) {
return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec2) => !invalidDotDotAfterRecursiveWildcard(spec2)), useCaseSensitiveFileNames2, currentDirectory);
return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec3) => !invalidDotDotAfterRecursiveWildcard(spec3)), useCaseSensitiveFileNames2, currentDirectory);
}
function matchesExcludeWorker(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory, basePath) {
const excludePattern = getRegularExpressionForWildcard(excludeSpecs, combinePaths(normalizePath(currentDirectory), basePath), "exclude");
@@ -40757,26 +40788,26 @@ ${lanes.join(`
return !hasExtension(pathToCheck) && excludeRegex.test(ensureTrailingDirectorySeparator(pathToCheck));
}
function validateSpecs(specs, errors, disallowTrailingRecursion, jsonSourceFile, specKey) {
return specs.filter((spec2) => {
if (!isString(spec2))
return specs.filter((spec3) => {
if (!isString(spec3))
return false;
const diag2 = specToDiagnostic(spec2, disallowTrailingRecursion);
const diag2 = specToDiagnostic(spec3, disallowTrailingRecursion);
if (diag2 !== undefined) {
errors.push(createDiagnostic(...diag2));
}
return diag2 === undefined;
});
function createDiagnostic(message, spec2) {
const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec2);
return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec2);
function createDiagnostic(message, spec3) {
const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec3);
return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec3);
}
}
function specToDiagnostic(spec2, disallowTrailingRecursion) {
Debug.assert(typeof spec2 === "string");
if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec2)) {
return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2];
} else if (invalidDotDotAfterRecursiveWildcard(spec2)) {
return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2];
function specToDiagnostic(spec3, disallowTrailingRecursion) {
Debug.assert(typeof spec3 === "string");
if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec3)) {
return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3];
} else if (invalidDotDotAfterRecursiveWildcard(spec3)) {
return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3];
}
}
function getWildcardDirectories({ validatedIncludeSpecs: include, validatedExcludeSpecs: exclude }, basePath, useCaseSensitiveFileNames2) {
@@ -40787,11 +40818,11 @@ ${lanes.join(`
if (include !== undefined) {
const recursiveKeys = [];
for (const file of include) {
const spec2 = normalizePath(combinePaths(basePath, file));
if (excludeRegex && excludeRegex.test(spec2)) {
const spec3 = normalizePath(combinePaths(basePath, file));
if (excludeRegex && excludeRegex.test(spec3)) {
continue;
}
const match = getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2);
const match = getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2);
if (match) {
const { key, path, flags } = match;
const existingPath = wildCardKeyToPath.get(key);
@@ -40822,20 +40853,20 @@ ${lanes.join(`
function toCanonicalKey(path, useCaseSensitiveFileNames2) {
return useCaseSensitiveFileNames2 ? path : toFileNameLowerCase(path);
}
function getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2) {
const match = wildcardDirectoryPattern.exec(spec2);
function getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2) {
const match = wildcardDirectoryPattern.exec(spec3);
if (match) {
const questionWildcardIndex = spec2.indexOf("?");
const starWildcardIndex = spec2.indexOf("*");
const lastDirectorySeperatorIndex = spec2.lastIndexOf(directorySeparator);
const questionWildcardIndex = spec3.indexOf("?");
const starWildcardIndex = spec3.indexOf("*");
const lastDirectorySeperatorIndex = spec3.lastIndexOf(directorySeparator);
return {
key: toCanonicalKey(match[0], useCaseSensitiveFileNames2),
path: match[0],
flags: questionWildcardIndex !== -1 && questionWildcardIndex < lastDirectorySeperatorIndex || starWildcardIndex !== -1 && starWildcardIndex < lastDirectorySeperatorIndex ? 1 : 0
};
}
if (isImplicitGlob(spec2.substring(spec2.lastIndexOf(directorySeparator) + 1))) {
const path = removeTrailingDirectorySeparator(spec2);
if (isImplicitGlob(spec3.substring(spec3.lastIndexOf(directorySeparator) + 1))) {
const path = removeTrailingDirectorySeparator(spec3);
return {
key: toCanonicalKey(path, useCaseSensitiveFileNames2),
path,
@@ -45962,7 +45993,7 @@ ${lanes.join(`
}
}
var ts_moduleSpecifiers_exports = {};
__export(ts_moduleSpecifiers_exports, {
__export2(ts_moduleSpecifiers_exports, {
RelativePreference: () => RelativePreference,
countPathComponents: () => countPathComponents,
forEachFileNameOfModule: () => forEachFileNameOfModule,
@@ -118075,7 +118106,7 @@ ${lanes.join(`
}
}
var ts_JsTyping_exports = {};
__export(ts_JsTyping_exports, {
__export2(ts_JsTyping_exports, {
NameValidationResult: () => NameValidationResult,
discoverTypings: () => discoverTypings,
isTypingUpToDate: () => isTypingUpToDate,
@@ -121532,8 +121563,8 @@ ${lanes.join(`
}
}
function getIsExcludedPatterns(preferences, useCaseSensitiveFileNames2) {
return mapDefined(preferences.autoImportFileExcludePatterns, (spec2) => {
const pattern = getSubPatternFromSpec(spec2, "", "exclude");
return mapDefined(preferences.autoImportFileExcludePatterns, (spec3) => {
const pattern = getSubPatternFromSpec(spec3, "", "exclude");
return pattern ? getRegexFromPattern(pattern, useCaseSensitiveFileNames2) : undefined;
});
}
@@ -124366,7 +124397,7 @@ interface Symbol {
return options;
}
var ts_NavigateTo_exports = {};
__export(ts_NavigateTo_exports, {
__export2(ts_NavigateTo_exports, {
getNavigateToItems: () => getNavigateToItems
});
function getNavigateToItems(sourceFiles, checker, cancellationToken, searchValue, maxResultCount, excludeDtsFiles, excludeLibFiles, program) {
@@ -124471,7 +124502,7 @@ interface Symbol {
};
}
var ts_NavigationBar_exports = {};
__export(ts_NavigationBar_exports, {
__export2(ts_NavigationBar_exports, {
getNavigationBarItems: () => getNavigationBarItems,
getNavigationTree: () => getNavigationTree
});
@@ -125200,7 +125231,7 @@ interface Symbol {
return text.replace(/\\?(?:\r?\n|[\r\u2028\u2029])/g, "");
}
var ts_refactor_exports = {};
__export(ts_refactor_exports, {
__export2(ts_refactor_exports, {
addExportsInOldFile: () => addExportsInOldFile,
addImportsForMovedSymbols: () => addImportsForMovedSymbols,
addNewFileToTsconfig: () => addNewFileToTsconfig,
@@ -125345,8 +125376,8 @@ interface Symbol {
if (wasDefault) {
if (isExportAssignment(exportNode) && !exportNode.isExportEquals) {
const exp = exportNode.expression;
const spec2 = makeExportSpecifier(exp.text, exp.text);
changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec2])));
const spec3 = makeExportSpecifier(exp.text, exp.text);
changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec3])));
} else {
changes.delete(exportingSourceFile, Debug.checkDefined(findModifier(exportNode, 90), "Should find a default keyword in modifier list"));
}
@@ -125397,17 +125428,17 @@ interface Symbol {
break;
case 277:
case 282: {
const spec2 = parent2;
changes.replaceNode(importingSourceFile, spec2, makeImportSpecifier(exportName, spec2.name.text));
const spec3 = parent2;
changes.replaceNode(importingSourceFile, spec3, makeImportSpecifier(exportName, spec3.name.text));
break;
}
case 274: {
const clause = parent2;
Debug.assert(clause.name === ref, "Import clause name should match provided ref");
const spec2 = makeImportSpecifier(exportName, ref.text);
const spec3 = makeImportSpecifier(exportName, ref.text);
const { namedBindings } = clause;
if (!namedBindings) {
changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec2]));
changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec3]));
} else if (namedBindings.kind === 275) {
changes.deleteRange(importingSourceFile, { pos: ref.getStart(importingSourceFile), end: namedBindings.getStart(importingSourceFile) });
const quotePreference = isStringLiteral(clause.parent.moduleSpecifier) ? quotePreferenceFromString(clause.parent.moduleSpecifier, importingSourceFile) : 1;
@@ -125415,7 +125446,7 @@ interface Symbol {
changes.insertNodeAfter(importingSourceFile, clause.parent, newImport);
} else {
changes.delete(importingSourceFile, ref);
changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec2);
changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec3);
}
break;
}
@@ -128196,7 +128227,7 @@ ${newComment.split(`
}
}
var ts_refactor_extractSymbol_exports = {};
__export(ts_refactor_extractSymbol_exports, {
__export2(ts_refactor_extractSymbol_exports, {
Messages: () => Messages,
RangeFacts: () => RangeFacts,
getRangeToExtract: () => getRangeToExtract2,
@@ -132122,7 +132153,7 @@ ${newComment.split(`
return result;
}
var ts_BreakpointResolver_exports = {};
__export(ts_BreakpointResolver_exports, {
__export2(ts_BreakpointResolver_exports, {
spanInSourceFileAtLocation: () => spanInSourceFileAtLocation
});
function spanInSourceFileAtLocation(sourceFile, position) {
@@ -132600,7 +132631,7 @@ ${newComment.split(`
}
}
var ts_CallHierarchy_exports = {};
__export(ts_CallHierarchy_exports, {
__export2(ts_CallHierarchy_exports, {
createCallHierarchyItem: () => createCallHierarchyItem,
getIncomingCalls: () => getIncomingCalls,
getOutgoingCalls: () => getOutgoingCalls,
@@ -133009,11 +133040,11 @@ ${newComment.split(`
return group(collectCallSites(program, declaration), getCallSiteGroupKey, (entries) => convertCallSiteGroupToOutgoingCall(program, entries));
}
var ts_classifier_exports = {};
__export(ts_classifier_exports, {
__export2(ts_classifier_exports, {
v2020: () => ts_classifier_v2020_exports
});
var ts_classifier_v2020_exports = {};
__export(ts_classifier_v2020_exports, {
__export2(ts_classifier_v2020_exports, {
TokenEncodingConsts: () => TokenEncodingConsts,
TokenModifier: () => TokenModifier,
TokenType: () => TokenType,
@@ -133021,7 +133052,7 @@ ${newComment.split(`
getSemanticClassifications: () => getSemanticClassifications2
});
var ts_codefix_exports = {};
__export(ts_codefix_exports, {
__export2(ts_codefix_exports, {
PreserveOptionalFlags: () => PreserveOptionalFlags,
addNewNodeForMemberSymbol: () => addNewNodeForMemberSymbol,
codeFixAll: () => codeFixAll,
@@ -136454,13 +136485,13 @@ ${newComment.split(`
changes.replaceNode(sourceFile, clause.namedBindings, factory.updateNamedImports(clause.namedBindings, toSorted([...existingSpecifiers.filter((s) => !removeExistingImportSpecifiers.has(s)), ...newSpecifiers], specifierComparer)));
} else if ((existingSpecifiers == null ? undefined : existingSpecifiers.length) && isSorted !== false) {
const transformedExistingSpecifiers = promoteFromTypeOnly2 && existingSpecifiers ? factory.updateNamedImports(clause.namedBindings, sameMap(existingSpecifiers, (e) => factory.updateImportSpecifier(e, true, e.propertyName, e.name))).elements : existingSpecifiers;
for (const spec2 of newSpecifiers) {
const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec2, specifierComparer);
changes.insertImportSpecifierAtIndex(sourceFile, spec2, clause.namedBindings, insertionIndex);
for (const spec3 of newSpecifiers) {
const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec3, specifierComparer);
changes.insertImportSpecifierAtIndex(sourceFile, spec3, clause.namedBindings, insertionIndex);
}
} else if (existingSpecifiers == null ? undefined : existingSpecifiers.length) {
for (const spec2 of newSpecifiers) {
changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec2, existingSpecifiers);
for (const spec3 of newSpecifiers) {
changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec3, existingSpecifiers);
}
} else {
if (newSpecifiers.length) {
@@ -142220,7 +142251,7 @@ ${newComment.split(`
}
}
var ts_Completions_exports = {};
__export(ts_Completions_exports, {
__export2(ts_Completions_exports, {
CompletionKind: () => CompletionKind,
CompletionSource: () => CompletionSource,
SortText: () => SortText,
@@ -145770,7 +145801,7 @@ ${newComment.split(`
return keyword === "abstract" || keyword === "async" || keyword === "await" || keyword === "declare" || keyword === "module" || keyword === "namespace" || keyword === "type" || keyword === "satisfies" || keyword === "as";
}
var ts_Completions_StringCompletions_exports = {};
__export(ts_Completions_StringCompletions_exports, {
__export2(ts_Completions_StringCompletions_exports, {
getStringLiteralCompletionDetails: () => getStringLiteralCompletionDetails,
getStringLiteralCompletions: () => getStringLiteralCompletions
});
@@ -146603,7 +146634,7 @@ ${newComment.split(`
return isCallExpression(node.parent) && firstOrUndefined(node.parent.arguments) === node && isIdentifier(node.parent.expression) && node.parent.expression.escapedText === "require";
}
var ts_FindAllReferences_exports = {};
__export(ts_FindAllReferences_exports, {
__export2(ts_FindAllReferences_exports, {
Core: () => Core,
DefinitionKind: () => DefinitionKind,
EntryKind: () => EntryKind,
@@ -148818,7 +148849,7 @@ ${newComment.split(`
}
})(Core || (Core = {}));
var ts_GoToDefinition_exports = {};
__export(ts_GoToDefinition_exports, {
__export2(ts_GoToDefinition_exports, {
createDefinitionInfo: () => createDefinitionInfo,
getDefinitionAndBoundSpan: () => getDefinitionAndBoundSpan,
getDefinitionAtPosition: () => getDefinitionAtPosition,
@@ -149314,7 +149345,7 @@ ${newComment.split(`
}
}
var ts_InlayHints_exports = {};
__export(ts_InlayHints_exports, {
__export2(ts_InlayHints_exports, {
provideInlayHints: () => provideInlayHints
});
var leadingParameterNameCommentRegexFactory = (name) => {
@@ -150076,7 +150107,7 @@ ${newComment.split(`
}
}
var ts_JsDoc_exports = {};
__export(ts_JsDoc_exports, {
__export2(ts_JsDoc_exports, {
getDocCommentTemplateAtPosition: () => getDocCommentTemplateAtPosition,
getJSDocParameterNameCompletionDetails: () => getJSDocParameterNameCompletionDetails,
getJSDocParameterNameCompletions: () => getJSDocParameterNameCompletions,
@@ -150496,7 +150527,7 @@ ${newComment.split(`
}
}
var ts_MapCode_exports = {};
__export(ts_MapCode_exports, {
__export2(ts_MapCode_exports, {
mapCode: () => mapCode
});
function mapCode(sourceFile, contents, focusLocations, host, formatContext, preferences) {
@@ -150631,7 +150662,7 @@ ${content}
node.forEachChild(resetNodePositions);
}
var ts_OrganizeImports_exports = {};
__export(ts_OrganizeImports_exports, {
__export2(ts_OrganizeImports_exports, {
compareImportsOrRequireStatements: () => compareImportsOrRequireStatements,
compareModuleSpecifiers: () => compareModuleSpecifiers2,
getImportDeclarationInsertionIndex: () => getImportDeclarationInsertionIndex,
@@ -151214,7 +151245,7 @@ ${content}
return compareModuleSpecifiersWorker(m1, m2, comparer);
}
var ts_OutliningElementsCollector_exports = {};
__export(ts_OutliningElementsCollector_exports, {
__export2(ts_OutliningElementsCollector_exports, {
collectElements: () => collectElements
});
function collectElements(sourceFile, cancellationToken) {
@@ -151532,7 +151563,7 @@ ${content}
return findChildOfKind(body, 19, sourceFile);
}
var ts_Rename_exports = {};
__export(ts_Rename_exports, {
__export2(ts_Rename_exports, {
getRenameInfo: () => getRenameInfo,
nodeIsEligibleForRename: () => nodeIsEligibleForRename
});
@@ -151686,7 +151717,7 @@ ${content}
}
}
var ts_SignatureHelp_exports = {};
__export(ts_SignatureHelp_exports, {
__export2(ts_SignatureHelp_exports, {
getArgumentInfoForCompletions: () => getArgumentInfoForCompletions,
getSignatureHelpItems: () => getSignatureHelpItems
});
@@ -152160,7 +152191,7 @@ ${content}
return { name: typeParameter.symbol.name, documentation: typeParameter.symbol.getDocumentationComment(checker), displayParts, isOptional: false, isRest: false };
}
var ts_SmartSelectionRange_exports = {};
__export(ts_SmartSelectionRange_exports, {
__export2(ts_SmartSelectionRange_exports, {
getSmartSelectionRange: () => getSmartSelectionRange
});
function getSmartSelectionRange(pos, sourceFile) {
@@ -152352,7 +152383,7 @@ ${content}
}
}
var ts_SymbolDisplay_exports = {};
__export(ts_SymbolDisplay_exports, {
__export2(ts_SymbolDisplay_exports, {
getSymbolDisplayPartsDocumentationAndSymbolKind: () => getSymbolDisplayPartsDocumentationAndSymbolKind,
getSymbolKind: () => getSymbolKind,
getSymbolModifiers: () => getSymbolModifiers
@@ -153058,7 +153089,7 @@ ${content}
});
}
var ts_textChanges_exports = {};
__export(ts_textChanges_exports, {
__export2(ts_textChanges_exports, {
ChangeTracker: () => ChangeTracker,
LeadingTriviaOption: () => LeadingTriviaOption,
TrailingTriviaOption: () => TrailingTriviaOption,
@@ -154255,7 +154286,7 @@ ${options.prefix}` : `
});
}
var ts_formatting_exports = {};
__export(ts_formatting_exports, {
__export2(ts_formatting_exports, {
FormattingContext: () => FormattingContext,
FormattingRequestKind: () => FormattingRequestKind,
RuleAction: () => RuleAction,
@@ -156640,7 +156671,7 @@ ${options.prefix}` : `
}
})(SmartIndenter || (SmartIndenter = {}));
var ts_preparePasteEdits_exports = {};
__export(ts_preparePasteEdits_exports, {
__export2(ts_preparePasteEdits_exports, {
preparePasteEdits: () => preparePasteEdits
});
function preparePasteEdits(sourceFile, copiedFromRange, checker) {
@@ -156672,7 +156703,7 @@ ${options.prefix}` : `
return shouldProvidePasteEdits;
}
var ts_PasteEdits_exports = {};
__export(ts_PasteEdits_exports, {
__export2(ts_PasteEdits_exports, {
pasteEditsProvider: () => pasteEditsProvider
});
var fixId55 = "providePostPasteEdits";
@@ -156763,7 +156794,7 @@ ${options.prefix}` : `
};
}
var ts_exports2 = {};
__export(ts_exports2, {
__export2(ts_exports2, {
ANONYMOUS: () => ANONYMOUS,
AccessFlags: () => AccessFlags,
AssertionLevel: () => AssertionLevel,
@@ -159104,7 +159135,7 @@ ${options.prefix}` : `
};
}
var ts_server_exports3 = {};
__export(ts_server_exports3, {
__export2(ts_server_exports3, {
ActionInvalidate: () => ActionInvalidate,
ActionPackageInstalled: () => ActionPackageInstalled,
ActionSet: () => ActionSet,
@@ -159202,7 +159233,7 @@ ${options.prefix}` : `
updateProjectIfDirty: () => updateProjectIfDirty
});
var ts_server_typingsInstaller_exports = {};
__export(ts_server_typingsInstaller_exports, {
__export2(ts_server_typingsInstaller_exports, {
TypingsInstaller: () => TypingsInstaller,
getNpmCommandForInstallation: () => getNpmCommandForInstallation,
installNpmPackages: () => installNpmPackages,
@@ -159757,7 +159788,7 @@ ${options.prefix}` : `
return base === "tsconfig.json" || base === "jsconfig.json" ? base : undefined;
}
var ts_server_protocol_exports = {};
__export(ts_server_protocol_exports, {
__export2(ts_server_protocol_exports, {
ClassificationType: () => ClassificationType,
CommandTypes: () => CommandTypes,
CompletionTriggerKind: () => CompletionTriggerKind,
@@ -169359,7 +169390,7 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
_TypingsInstallerAdapter.requestDelayMillis = 100;
var TypingsInstallerAdapter = _TypingsInstallerAdapter;
var ts_server_exports4 = {};
__export(ts_server_exports4, {
__export2(ts_server_exports4, {
ActionInvalidate: () => ActionInvalidate,
ActionPackageInstalled: () => ActionPackageInstalled,
ActionSet: () => ActionSet,
@@ -169485,10 +169516,10 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
// node_modules/.bun/@vscode+l10n@0.0.18/node_modules/@vscode/l10n/dist/main.js
var require_main = __commonJS((exports2, module2) => {
var __defProp2 = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
var __getOwnPropNames2 = Object.getOwnPropertyNames;
var __hasOwnProp2 = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
var __export2 = (target, all) => {
for (var name in all)
__defProp2(target, name, { get: all[name], enumerable: true });
};
@@ -169496,17 +169527,17 @@ var require_main = __commonJS((exports2, module2) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames2(from))
if (!__hasOwnProp2.call(to, key) && key !== except)
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod);
var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod);
var main_exports = {};
__export(main_exports, {
__export2(main_exports, {
config: () => config,
t: () => t
});
module2.exports = __toCommonJS(main_exports);
module2.exports = __toCommonJS2(main_exports);
var import_fs = require("fs");
var import_promises = require("fs/promises");
async function readFileFromUri(uri) {
@@ -169624,6 +169655,14 @@ var require_main = __commonJS((exports2, module2) => {
}
});
// packages/language-server/src/server.ts
var exports_server = {};
__export(exports_server, {
apiCallHover: () => apiCallHover,
apiCallCompletions: () => apiCallCompletions
});
module.exports = __toCommonJS(exports_server);
// packages/syntax/src/tokenizer.ts
class LexError extends Error {
}
@@ -170866,8 +170905,34 @@ function parseApiSections(source) {
error: top.get("error")?.text ?? ""
};
}
function hasRequestSection(source) {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
function parseApiEntries(source) {
const entries = [];
const lx = new Lexer(source);
while (lx.peek().type !== "eof") {
const nameToken = lx.next();
if (nameToken.type !== "ident") {
throw new LexError(`Expected an api entry name at offset ${nameToken.pos}`);
}
const methodToken = lx.next();
if (methodToken.type !== "ident") {
throw new LexError(`Expected an HTTP method after "${nameToken.value}"`);
}
const path = lx.readPath();
const entryBody = lx.readBalancedBraces();
const sections = parseApiSections(entryBody);
if (sections === null) {
throw new LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`);
}
entries.push({
mode: "any",
name: nameToken.value,
method: methodToken.value.toUpperCase(),
path,
body: "",
sections
});
}
return entries;
}
// packages/syntax/src/types.ts
@@ -171090,7 +171155,7 @@ function parseRuntimeFunctions(source) {
i++;
continue;
}
let runtime = "legacy";
let runtime = "shared";
if (["client", "server", "shared"].includes(token.word)) {
runtime = token.word;
i = skipTrivia(source, token.end);
@@ -171720,7 +171785,6 @@ function parse(source) {
case "client":
case "server": {
const rawMode = kw.value;
const mode = rawMode === "client" ? "client" : "ssr";
lx.next();
if ((rawMode === "client" || rawMode === "server") && lx.peek().type === "ident" && lx.peek().value === "state") {
lx.next();
@@ -171729,52 +171793,15 @@ function parse(source) {
states.push(...parseStateDeclarations(lx.readBalancedBraces(), rawMode));
break;
}
if (mode === "client" && lx.peek().type === "eq") {
if (rawMode === "client" && lx.peek().type === "eq") {
lx.next();
hydrate = expect("string").value;
break;
}
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const member = lx.peek();
if (member.type === "eof") {
throw new ParseError(`Unexpected end of input inside ${mode} block`);
}
if (member.type !== "ident") {
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
}
switch (member.value) {
case "api": {
lx.next();
const name2 = expect("ident").value;
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
const sections = parseApiSections(body);
if (sections && mode !== "client" && hasRequestSection(body)) {
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
}
dataApis.push({
mode,
name: name2,
method,
path,
body: sections ? "" : body,
...sections ? { sections } : {}
});
break;
}
case "functions": {
lx.next();
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
break;
}
default:
throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`);
}
if (lx.peek().type === "lbrace") {
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
}
expect("rbrace");
break;
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
}
case "shared": {
lx.next();
@@ -171881,6 +171908,12 @@ function parse(source) {
persist = parsePersist(lx.readBalancedBraces());
break;
}
case "apis": {
lx.next();
const body = lx.readBalancedBraces();
dataApis.push(...parseApiEntries(body));
break;
}
default:
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
}
@@ -171950,6 +171983,13 @@ function parse(source) {
};
for (const name2 of namedLoads.keys())
visitLoad(name2);
const seenApiNames = new Set;
for (const block of dataApis) {
if (seenApiNames.has(block.name)) {
throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE");
}
seenApiNames.add(block.name);
}
return {
type: "page",
imports,
@@ -172040,12 +172080,18 @@ function parseHtmlView(src, pos) {
if (quote !== '"' && quote !== "'")
return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote)
let value = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && (src[i + 1] === quote || src[i + 1] === "\\")) {
value += src[i + 1];
i += 2;
continue;
}
value += src[i];
i++;
}
if (i >= src.length)
return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++;
return value;
};
@@ -172681,7 +172727,7 @@ function componentContract(ast) {
`);
const outputs = ast.outputs.map((output) => ` ${safe(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`).join(`
`);
const callable = ast.runtimeFunctions.filter((fn) => fn.runtime !== "legacy").map((fn) => ` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`).join(`
const callable = ast.runtimeFunctions.map((fn) => ` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`).join(`
`);
return `${typeSource ? `${typeSource}
@@ -172960,7 +173006,7 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
append(importedWrnDeclarations(ast, filePath, appRoot));
const sharedNames = new Set(ast.runtimeFunctions.filter((fn) => fn.runtime === "shared").map((fn) => fn.name));
for (const runtime of ["shared", "client", "server", "legacy"]) {
for (const runtime of ["shared", "client", "server"]) {
const functions = ast.runtimeFunctions.filter((fn) => fn.runtime === runtime);
if (!functions.length)
continue;
@@ -173583,7 +173629,7 @@ function htmlToWrn(html, name = "ImportedPage") {
}
// packages/language-server/src/index.ts
var WRN_COMPLETIONS = [
var WRN_KEYWORDS = [
"page",
"component",
"layout",
@@ -173597,6 +173643,7 @@ var WRN_COMPLETIONS = [
"load",
"action",
"api",
"apis",
"realtime",
"view",
"style",
@@ -173759,7 +173806,7 @@ ${declaration[0]}
};
}
function completionItems() {
return WRN_COMPLETIONS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
return WRN_KEYWORDS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
}
var semanticTokenTypes = ["variable"];
var semanticTokenModifiers = ["declaration", "modification"];
@@ -196156,6 +196203,46 @@ function clearHtmlRegionCache(uri) {
}
// packages/language-server/src/server.ts
function dataApisOf(source) {
try {
return parse(source).dataApis;
} catch {
return [];
}
}
function apiDetail(block) {
return `${block.method} ${block.path}`;
}
function apiCallCompletions(source) {
return dataApisOf(source).map((block) => ({
label: block.name,
kind: 2,
detail: apiDetail(block),
documentation: block.sections ? [
block.sections.body.length ? `body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}` : undefined
].filter(Boolean).join(`
`) : undefined
}));
}
function apiCallHover(source, name) {
const block = dataApisOf(source).find((entry) => entry.name === name);
if (!block)
return;
const lines = [`**${block.name}** \`${block.method} ${block.path}\``];
if (block.sections?.body.length) {
lines.push(`request body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`);
}
if (block.sections?.parameters.length) {
lines.push(`parameters: ${block.sections.parameters.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`);
}
return lines.join(`
`);
}
function isApiCallPosition(text, offset) {
const before = text.slice(0, offset);
return /\bapi\.\w*$/.test(before);
}
var documents = new Map;
var diagnosticTimers = new Map;
var MAX_OPEN_DOCUMENTS = 256;
@@ -196328,6 +196415,10 @@ async function handle(message) {
}
case "textDocument/completion": {
const document = documents.get(params.textDocument.uri);
if (document && isApiCallPosition(document.text, offsetAt(document.text, params.position))) {
result(message.id, apiCallCompletions(document.text));
break;
}
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
const html = document ? htmlCompletions(document, params.position) : [];
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
@@ -196345,6 +196436,12 @@ async function handle(message) {
}
case "textDocument/hover": {
const document = documents.get(params.textDocument.uri);
const word = document ? wordAt(document.text, params.position)?.word : undefined;
const apiHover = word ? apiCallHover(document.text, word) : undefined;
if (apiHover) {
result(message.id, { contents: apiHover });
break;
}
const html = document ? htmlHover(document, params.position) : null;
result(message.id, html ?? (document ? hover(document, params.position) : null));
break;
+37 -15
View File
@@ -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"
}
]
},
+133
View File
@@ -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'",
);
});
+295
View File
@@ -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.
@@ -3,8 +3,8 @@ page ApiBlockDemo {
state found = ""
state failed = ""
client {
api searchDirectory POST /api/directory {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
@@ -21,6 +21,11 @@ page ApiBlockDemo {
}
}
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 })
@@ -33,6 +38,12 @@ page ApiBlockDemo {
<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>
}
}
+20 -25
View File
@@ -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(", ")
}
}
+3
View File
@@ -1,6 +1,9 @@
{
"name": "basic-app",
"version": "0.8.0",
"wrnexus": {
"version": "0.9.0"
},
"private": true,
"type": "module",
"scripts": {
+276
View File
@@ -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.
-2
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
dist/
.wrnexus/
*.sqlite
*.sqlite-shm
*.sqlite-wal
+13
View File
@@ -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.
+35
View File
@@ -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 });
}
+33
View File
@@ -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),
},
});
}
+15
View File
@@ -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 });
}
+19
View File
@@ -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;
+23
View File
@@ -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"],
);
}
+32
View File
@@ -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" };
},
});
+29
View File
@@ -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"],
);
}
}
+11
View File
@@ -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;
+5
View File
@@ -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> }
}
+6
View File
@@ -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> }
}
+8
View File
@@ -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> }
}
+6
View File
@@ -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> }
}
+6
View File
@@ -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>
}
}
+31
View File
@@ -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>
}
}
+10
View File
@@ -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>
}
}
+4
View File
@@ -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> }
}
+10
View File
@@ -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>
}
}
+116
View File
@@ -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;
}
+375
View File
@@ -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 {};
+47
View File
@@ -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.
+30
View File
@@ -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"
}
}
+82
View File
@@ -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)');
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
}
+38
View File
@@ -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: {
+19 -7
View File
@@ -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; }
}
}
+13 -13
View File
@@ -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 -1
View File
@@ -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,
+1 -9
View File
@@ -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 {
+3 -11
View File
@@ -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 {
+1 -17
View File
@@ -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,
-2
View File
@@ -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,
});
}
-5
View File
@@ -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;
}
-2
View File
@@ -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;
+11
View File
@@ -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"],
-4
View File
@@ -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);
+89 -10
View File
@@ -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 () => {
-7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.46",
"version": "0.8.47",
"type": "module",
"main": "src/index.ts",
"exports": {
-76
View File
@@ -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;
}
-9
View File
@@ -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
View File
@@ -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:
-12
View File
@@ -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";
+285
View File
@@ -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 };
}
+13 -12
View File
@@ -138,19 +138,20 @@ function apiBlockAssertions(
for (const block of page.ast.dataApis) {
if (!block.sections) continue;
// ssr-mode sectioned blocks can never declare a `request` (they are
// render-time only), so they always fall back to the empty-shape
// `Record<string, never>` below. `keyof Record<string, never>` is
// `string`, which makes the key-exactness arm of AssertAssignable
// evaluate to `false` unconditionally and raises TS2344 on every such
// block regardless of whether the block author did anything wrong.
// We choose to skip emission for both (a) any non-client-mode block,
// since it structurally can never have a request to check, and (b) any
// block -- client included -- that has zero declared request fields,
// since there is nothing to assert type-safety about. This is more
// honest about intent than emitting a vacuous/always-failing check.
// 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 (block.mode !== "client" || fields.length === 0) continue;
if (fields.length === 0) continue;
if (!apiContracts.includes(JSON.stringify(block.path))) {
console.warn(
+199 -1346
View File
File diff suppressed because it is too large Load Diff
@@ -9,9 +9,8 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/** Minimal app with one typed endpoint and one page that calls it. */
function fixture(block: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
function fixture(apisBlock: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
@@ -21,26 +20,38 @@ function fixture(block: string): string {
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
`page Search {\n apis {\n${apisBlock}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
const BLOCK = ` api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
}
}
test("a mode-less block with declared fields gets an assertion", () => {
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}`);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
response {
return data.users
}
}`;
expect(generated).toContain("searchUsers");
expect(generated).toContain('ApiInput<"/api/users", "POST">');
});
test("a block with no declared fields gets no assertion", () => {
const root = fixture(` listAll GET /api/users {
response { return data.users }
}`);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(generated).not.toContain("listAll");
});
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
const root = fixture(BLOCK);
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string; age?: number } }
response { return data.users }
}`);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
@@ -56,7 +67,10 @@ test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
// never actually enforce anything here. A real .ts file under app/ is compiled and
// checked normally.
test("emits one assertion per sectioned block, naming its route and method", () => {
const root = fixture(BLOCK);
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string; age?: number } }
response { return data.users }
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
@@ -66,18 +80,11 @@ test("emits one assertion per sectioned block, naming its route and method", ()
expect(checks).toContain("age?: number");
});
test("a legacy bare-body block produces no assertion", () => {
const root = fixture(` api legacyUsers GET /api/users {
return users.length
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
});
test("the api-checks file has no runtime code and is a module", () => {
const root = fixture(BLOCK);
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
@@ -86,7 +93,7 @@ test("the api-checks file has no runtime code and is a module", () => {
});
test("B1: two pages each declaring a block with the same name do not collide", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-collide-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
@@ -94,13 +101,17 @@ test("B1: two pages each declaring a block with the same name do not collide", (
join(root, "app/api/users.ts"),
`export const POST = async () => Response.json({ users: [] });\n`,
);
const block = ` searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}`;
writeFileSync(
join(root, "app/pages/one.wrn"),
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
`page One {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
writeFileSync(
join(root, "app/pages/two.wrn"),
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
`page Two {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
@@ -111,43 +122,11 @@ test("B1: two pages each declaring a block with the same name do not collide", (
expect(new Set(names).size).toBe(2);
});
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const GET = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/ssr.wrn"),
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("loadUsers");
});
test("B2: a client block with an empty request emits no assertion", () => {
const root = fixture(` api pingServer GET /api/users {
response {
return data.users
}
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("pingServer");
});
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
const root = fixture(BLOCK);
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
@@ -172,7 +151,7 @@ test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it",
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
// could ever fail, which would make these tests meaningless.
function typedFixture(block: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-tsc-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
@@ -182,7 +161,7 @@ function typedFixture(block: string): string {
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
`page Search {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
@@ -217,7 +196,7 @@ function typecheckGenerated(root: string): { ok: boolean; output: string } {
return { ok: result.exitCode === 0, output };
}
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
const MATCHING_BLOCK = ` searchUsers POST /api/users {
request {
body {
name: string
@@ -240,7 +219,7 @@ test("tsc: a block whose fields match the contract has no diagnostics", () => {
});
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
const root = typedFixture(` api searchUsers POST /api/users {
const root = typedFixture(` searchUsers POST /api/users {
request {
body {
name: number
@@ -263,7 +242,7 @@ test("tsc: a field with the wrong type fails, naming the block's assertion", ()
});
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
const root = typedFixture(` api searchUsers POST /api/users {
const root = typedFixture(` searchUsers POST /api/users {
request {
body {
name: string
@@ -287,7 +266,7 @@ test("tsc: an extra field the contract does not accept fails (Finding A regressi
});
test("tsc: a missing required field fails", () => {
const root = typedFixture(` api searchUsers POST /api/users {
const root = typedFixture(` searchUsers POST /api/users {
request {
body {
name: string
-17
View File
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { compatibilityReport, upgradeCompatibility } from "../src/compatibility-command.ts";
test("compatibility upgrade is backed up, current, and idempotent", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-compatibility-"));
const file = join(root, "wrnexus.config.ts");
writeFileSync(file, `export default { port: 3000 };\n`);
const first = upgradeCompatibility(root);
expect(first.changed).toBe(true);
expect(readFileSync(first.backup, "utf8")).toContain("port: 3000");
expect(readFileSync(file, "utf8")).toContain('compatibilityDate: "2026-08-02"');
expect(upgradeCompatibility(root).changed).toBe(false);
expect((await compatibilityReport(root)).needsUpgrade).toBe(false);
});
-1
View File
@@ -96,7 +96,6 @@ test("scaffoldApp includes the complete v0.8 configuration and starter structure
"imports:",
"types:",
"stores:",
"compatibility:",
"performance:",
"observability:",
"tenancy:",
@@ -0,0 +1,142 @@
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");
});
test("a page with an existing apis block merges legacy entries instead of producing two apis blocks", () => {
const source = `page P {
apis {
existing GET /api/existing {
response { return data }
}
}
client {
api added POST /api/added {
response { return data.value }
}
}
view { <main>x</main> }
}
`;
const result = migrateApisBlock(source) as { source: string; changed: boolean };
expect(result.changed).toBe(true);
const apisCount = (result.source.match(/\bapis\s*\{/g) ?? []).length;
expect(apisCount).toBe(1);
expect(result.source).toContain("existing GET /api/existing");
expect(result.source).toContain("added POST /api/added");
expect(result.source).not.toContain("client {");
});
test("a mode block mixing api entries with other content is skipped, not partially rewritten", () => {
// A bare `ssr { … }` block is invalid syntax once api entries are removed
// (only `state` and hydrate forms survive) -- leftover content such as
// `functions { }` belongs to the move-mode-functions migration, so this
// file cannot be completed by this migration alone.
const source = `page P {
ssr {
api foo GET /api/foo {
response { return data }
}
functions {
function helper() { return 1 }
}
}
view { <main>x</main> }
}
`;
const result = migrateApisBlock(source) as { skip: string };
expect(result.skip).toContain("ssr");
expect(result.skip).toContain("functions");
});
test("a legacy bare-body api entry is left alone instead of producing a bogus parse failure", () => {
// No request/response/error sections -- this is the legacy bare-body form
// that `report-legacy-api-bodies` handles for manual review. Folding it
// into `apis { }` as-is would produce source the grammar rejects, so this
// migration must leave it untouched rather than attempt the rewrite.
const source = `page Hello {
ssr {
api ssrUsers GET /api/users/ssr {
return userNames(users)
}
}
view { <main>x</main> }
}
`;
const result = migrateApisBlock(source) as { source: string; changed: boolean };
expect(result.changed).toBe(false);
expect(result.source).toBe(source);
});
test("a brace inside a string literal in a section body does not truncate the entry", () => {
const source = `page P {
client {
api foo GET /api/foo {
response { return { text: "a } b" } }
}
}
view { <main>x</main> }
}
`;
const result = migrateApisBlock(source) as { source: string; changed: boolean };
expect(result.changed).toBe(true);
expect(result.source).toContain('text: "a } b"');
expect(result.source).toContain("apis {");
});
@@ -0,0 +1,92 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { updateApp } 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 },
};
`;
const NESTED_CONFIG = `export default {
compatibilityDate: "2026-08-02",
frameworkBehaviour: 1,
functions: { legacyDefaultRuntime: "current", overrides: { a: { b: 1 } } },
compatibility: { legacyEmit: false, nested: { deeper: { value: true } } },
observability: { sampleRate: 1 },
};
`;
function project(config = CONFIG): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-"));
roots.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "config-migrate-app",
dependencies: { "@wrnexus/core": "^0.8.0" },
wrnexus: { version: "0.8.0" },
}),
);
writeFileSync(join(root, "wrnexus.config.ts"), config);
return root;
}
test("the removed keys are deleted and the rest is kept", () => {
const root = project();
updateApp(root, "0.9.0", 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", () => {
const root = project();
updateApp(root, "0.9.0", false);
const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
updateApp(root, "0.9.0", false);
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once);
});
test("a dry run writes nothing", () => {
const root = project();
updateApp(root, "0.9.0", true);
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG);
});
test("a nested object value under a removed key does not corrupt the file", () => {
const root = project(NESTED_CONFIG);
updateApp(root, "0.9.0", 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).not.toContain("overrides");
expect(config).not.toContain("nested");
expect(config).toContain("observability");
// The file must remain valid, balanced TypeScript: an equal number of
// opening and closing braces, and it must still be parseable as a module.
const opens = (config.match(/\{/g) ?? []).length;
const closes = (config.match(/\}/g) ?? []).length;
expect(opens).toBe(closes);
});
@@ -0,0 +1,116 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
import { updateApp } from "../src/update.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([]);
});
test("an identifier that only appears inside a string literal is not collected", () => {
const source = `page Hello {
ssr {
api ssrUsers GET /api/users/ssr {
return "users are great, ask userNames"
}
}
view { <main>x</main> }
}
`;
const found = detectLegacyApiBodies(source);
expect(found).toHaveLength(1);
expect(found[0]!.freeIdentifiers).not.toContain("users");
expect(found[0]!.freeIdentifiers).not.toContain("userNames");
});
test("a property-access key is not collected, only its object", () => {
const source = `page Hello {
ssr {
api ssrUsers GET /api/users/ssr {
return u.name
}
}
view { <main>x</main> }
}
`;
const found = detectLegacyApiBodies(source);
expect(found).toHaveLength(1);
expect(found[0]!.freeIdentifiers).toContain("u");
expect(found[0]!.freeIdentifiers).not.toContain("name");
});
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function project(): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-legacy-api-body-"));
roots.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "legacy-api-body-migrate-app",
dependencies: { "@wrnexus/core": "^0.8.0" },
wrnexus: { version: "0.8.0" },
}),
);
return root;
}
test("a full update run leaves a legacy bare body file byte-identical and reports it", () => {
const root = project();
writeFileSync(join(root, "app", "Hello.wrn"), SOURCE);
const report = {
changedAutomatically: [] as string[],
needsReview: [] as string[],
unresolvedImports: [] as string[],
ambiguousFunctions: [] as string[],
legacyOutputPayloads: [] as string[],
parseFailures: [] as string[],
};
updateApp(root, "0.9.0", false, { report });
const after = readFileSync(join(root, "app", "Hello.wrn"), "utf8");
expect(after).toBe(SOURCE);
expect(report.needsReview.some((entry) => entry.includes("ssrUsers"))).toBe(true);
expect(report.parseFailures).toEqual([]);
});
@@ -0,0 +1,132 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
import { updateApp } from "../src/update.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");
});
test("a helper body containing a brace inside a string literal survives", () => {
const source = `page Hello {
ssr {
functions {
function label(user) {
return user.name + " {tag}"
}
}
}
view { <main>x</main> }
}
`;
const result = migrateModeFunctions(source) as { source: string; changed: boolean };
expect(result.changed).toBe(true);
expect(result.source).toContain("shared function label");
expect(result.source).toContain('" {tag}"');
});
test("both ssr and client declaring the same helper name is skipped with a reason", () => {
const clash = `page P {
ssr { functions { function helper() { return 1 } } }
client { functions { function helper() { return 2 } } }
view { <main>x</main> }
}
`;
const result = migrateModeFunctions(clash) as { skip: string };
expect(result.skip).toContain("helper");
});
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function project(): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-mode-functions-"));
roots.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "mode-functions-migrate-app",
dependencies: { "@wrnexus/core": "^0.8.0" },
wrnexus: { version: "0.8.0" },
}),
);
return root;
}
test("one update run fully migrates a page mixing api entries and mode functions", () => {
const root = project();
writeFileSync(
join(root, "app", "P.wrn"),
`page P {
ssr {
api getUsers GET /api/users { response { return data } }
functions { function label(u) { return u.name } }
}
view { <main>x</main> }
}
`,
);
const report = {
changedAutomatically: [] as string[],
needsReview: [] as string[],
unresolvedImports: [] as string[],
ambiguousFunctions: [] as string[],
legacyOutputPayloads: [] as string[],
parseFailures: [] as string[],
};
updateApp(root, "0.9.0", false, { report });
const migrated = readFileSync(join(root, "app", "P.wrn"), "utf8");
expect(migrated).toContain("apis {");
expect(migrated).toContain("shared function label");
expect(migrated).not.toContain("ssr {");
expect(report.needsReview).toEqual([]);
});
@@ -0,0 +1,81 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runUpdate } from "../src/update.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function project(name: string): string {
const root = mkdtempSync(join(tmpdir(), `wrnexus-update-exit-${name}-`));
roots.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: `update-exit-${name}`,
dependencies: { "@wrnexus/core": "^0.8.0" },
wrnexus: { version: "0.8.0" },
}),
);
return root;
}
/**
* `runUpdate` reports failure via `process.exitCode` (never a real
* `process.exit()` call) on this path -- see the existing `--delegated` and
* verification-failure branches in `src/update.ts`. `--delegated` skips the
* "fetch a newer published CLI" handoff (there is no published 0.9.0 yet),
* matching how a real newer CLI re-invokes itself. `--dry-run` keeps the test
* offline too: the dry-run path returns before `bun install`/verification
* ever run, so no network access is needed to observe the exit code this
* task adds.
*/
test("a project with a legacy bare body exits non-zero", async () => {
const root = project("needs-review");
writeFileSync(
join(root, "app", "Hello.wrn"),
`page Hello {
ssr {
api ssrUsers GET /api/users/ssr {
return userNames(users)
}
}
view { <main>x</main> }
}
`,
);
const before = process.exitCode;
process.exitCode = 0;
try {
await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]);
expect(process.exitCode).toBeTruthy();
} finally {
process.exitCode = before ?? 0;
}
});
test("a fully-migratable project exits zero", async () => {
const root = project("clean");
writeFileSync(
join(root, "app", "Hello.wrn"),
`page Hello {
view { <main>x</main> }
}
`,
);
const before = process.exitCode;
process.exitCode = 0;
try {
await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]);
expect(process.exitCode ?? 0).toBe(0);
} finally {
process.exitCode = before ?? 0;
}
});
@@ -0,0 +1,17 @@
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([]);
});
+64 -218
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -35,153 +35,6 @@ test("dependency updates use each package's independently published version", ()
expect(changes).toHaveLength(2);
});
test("updateApp migrates project files without marking an unverified update complete", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "legacy-app",
scripts: { dev: "wrnexus dev ." },
dependencies: { "@wrnexus/core": "0.2.13" },
wrnexus: { version: "0.2.13" },
}),
);
writeFileSync(join(root, ".gitignore"), "node_modules/\n");
try {
const result = updateApp(root, "0.2.14", false);
expect(result).not.toBeNull();
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.2.14");
expect(pkg.scripts.build).toBe("wrnexus build .");
expect(pkg.scripts.start).toBe("bun dist/server.js");
expect(pkg.scripts.production).toBe("bun run build && bun run start");
expect(pkg.wrnexus.version).toBe("0.2.13");
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
expect(gitignore).toContain("!.env.example");
expect(gitignore).toContain("mobile/android/");
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("0.2.18 migration adds helpers to existing apps and is idempotent", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "existing-app",
dependencies: { "@wrnexus/core": "^0.2.17" },
wrnexus: { version: "0.2.17" },
}),
);
try {
updateApp(root, "0.2.18", false);
updateApp(root, "0.2.18", false);
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
expect(pkg.dependencies["@wrnexus/helpers"]).toBe("^0.2.18");
expect(
Object.keys(pkg.dependencies).filter((name) => name === "@wrnexus/helpers"),
).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("0.2.70 migration pins the production runtime directly in runnable apps", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "existing-app",
dependencies: { "@wrnexus/core": "^0.2.69" },
wrnexus: { version: "0.2.69" },
}),
);
try {
updateApp(root, "0.2.70", false);
updateApp(root, "0.2.70", false);
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
expect(pkg.dependencies["@wrnexus/dev-server"]).toBe("^0.2.70");
expect(
Object.keys(pkg.dependencies).filter((name) => name === "@wrnexus/dev-server"),
).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("0.2.19 migration adds VS Code formatting defaults without overwriting settings", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
mkdirSync(join(root, ".vscode"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.18" } }),
);
writeFileSync(join(root, ".gitignore"), ".vscode/\n");
writeFileSync(join(root, ".prettierignore"), "node_modules/\n");
writeFileSync(join(root, ".vscode", "settings.json"), '{"editor.tabSize":4}\n');
try {
updateApp(root, "0.2.19", false);
updateApp(root, "0.2.19", false);
expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toBe(
'{"editor.tabSize":4}\n',
);
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain(
"wrnexus.wrnexus",
);
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toBe(
'{\n "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]\n}\n',
);
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toBe("node_modules/\nCLAUDE.md\n");
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
expect(gitignore).not.toContain(".vscode/\n");
expect(gitignore).toContain("!.vscode/settings.json");
expect(gitignore).toContain("!.vscode/extensions.json");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("0.2.20 migration repairs only the legacy generated recommendations layout", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
mkdirSync(join(root, ".vscode"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.19" } }),
);
writeFileSync(
join(root, ".vscode", "extensions.json"),
JSON.stringify(
{
recommendations: ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
},
null,
2,
) + "\n",
);
try {
updateApp(root, "0.2.20", false);
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toBe(
'{\n "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]\n}\n',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("update verification formats before checking and building", () => {
expect(
verificationCommands({
@@ -200,30 +53,6 @@ test("update verification skips unavailable scripts", () => {
expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]);
});
test("0.6 type declarations are created only inside runnable apps", () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-workspace-"));
const appRoot = join(workspaceRoot, "apps", "web");
mkdirSync(join(appRoot, "app", "pages"), { recursive: true });
writeFileSync(
join(workspaceRoot, "package.json"),
JSON.stringify({ name: "workspace", wrnexus: { version: "0.5.9" } }),
);
writeFileSync(
join(appRoot, "package.json"),
JSON.stringify({ name: "web", wrnexus: { version: "0.5.9" } }),
);
try {
updateApp(workspaceRoot, "0.6.0", false);
updateApp(appRoot, "0.6.0", false);
expect(existsSync(join(workspaceRoot, "app", "types", "global.d.ts"))).toBe(false);
expect(existsSync(join(appRoot, "app", "types", "global.d.ts"))).toBe(true);
} finally {
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("0.3 WRN source normalization is conservative and idempotent", async () => {
const { migrateWrnSource } = await import("../src/update.ts");
const source = `component Card {
@@ -282,61 +111,28 @@ test("update verification still blocks real doctor errors", () => {
).toEqual([fatal]);
});
test("0.4 migration removes manual CAPTCHA runtime wiring and archives copied assets", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
test("updateApp refreshes framework-owned files without marking an unverified update complete", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-unverified-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
mkdirSync(join(root, "public", "assets", "wrnexus"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "captcha-app",
scripts: { dev: "wrnexus dev ." },
dependencies: { "@wrnexus/captcha": "^0.3.6" },
wrnexus: { version: "0.3.6" },
name: "current-app",
dependencies: { "@wrnexus/core": "0.8.5" },
wrnexus: { version: "0.8.5" },
}),
);
writeFileSync(
join(root, "app", "pages", "index.wrn"),
`page Home {
view {
<Captcha type="number" action="login" />
<script src="/assets/wrnexus/captcha.js" defer></script>
}
}`,
);
writeFileSync(
join(root, "public", "assets", "wrnexus", "captcha.js"),
"window.legacyCaptcha = true;\n",
);
try {
updateApp(root, "0.4.0", false);
const page = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
const result = updateApp(root, "0.8.6", false);
expect(result).not.toBeNull();
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
expect(page).toContain("<Captcha");
expect(page).not.toContain("captcha.js");
expect(pkg.dependencies["@wrnexus/captcha"]).toBe("^0.4.0");
expect(pkg.scripts["inspect:runtimes"]).toBe("wrnexus inspect runtimes .");
expect(
readFileSync(
join(
root,
".wrnexus",
"legacy-assets",
"0.4.0",
"public",
"assets",
"wrnexus",
"captcha.js",
),
"utf8",
),
).toContain("legacyCaptcha");
expect(existsSync(join(root, "public", "assets", "wrnexus", "captcha.js"))).toBe(false);
const report = JSON.parse(
readFileSync(join(root, ".wrnexus", "migrations", "0.4.0.json"), "utf8"),
);
expect(report.manualCaptchaRuntimeRequired).toBe(false);
expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.8.6");
// updateApp only bumps dependency versions; the `wrnexus.version` marker is
// set later, once install/build/doctor verification actually succeeds.
expect(pkg.wrnexus.version).toBe("0.8.5");
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
expect(readFileSync(join(root, "CLAUDE.md"), "utf8").length).toBeGreaterThan(0);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -394,3 +190,53 @@ test("0.8 migration modernizes every WRN source with imports and a review report
rmSync(root, { recursive: true, force: true });
}
});
test("updateApp warns when a marker-less project is below 0.8.0, and stays quiet at 0.8.x", () => {
const belowRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-below-"));
const currentRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-current-"));
try {
// No `wrnexus.version` marker and no installed @wrnexus/cli resolves to
// "0.0.0" — the common, benign case (e.g. examples/basic-app).
writeFileSync(
join(belowRoot, "package.json"),
JSON.stringify({ name: "marker-less-app", dependencies: {} }),
);
writeFileSync(
join(currentRoot, "package.json"),
JSON.stringify({
name: "current-app",
dependencies: { "@wrnexus/core": "^0.8.5" },
wrnexus: { version: "0.8.5" },
}),
);
const originalLog = console.log;
const capture = (): string[] => {
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
return logs;
};
let belowLogs: string[];
let currentLogs: string[];
try {
belowLogs = capture();
updateApp(belowRoot, "0.8.9", true);
currentLogs = capture();
updateApp(currentRoot, "0.8.9", true);
} finally {
console.log = originalLog;
}
expect(belowLogs.some((l) => l.includes("no longer supported") && l.includes("0.0.0"))).toBe(
true,
);
expect(currentLogs.some((l) => l.includes("no longer supported"))).toBe(false);
} finally {
rmSync(belowRoot, { recursive: true, force: true });
rmSync(currentRoot, { recursive: true, force: true });
}
});
+114 -9
View File
@@ -1,5 +1,6 @@
import {
eraseFunctionTypes,
skipLiteralOrComment,
type PageAst,
type RuntimeFunctionDecl,
type StructuredImportDecl,
@@ -169,9 +170,7 @@ function selectedBrowserImports(
}
export function browserModuleRequired(ast: PageAst): boolean {
const functions = ast.runtimeFunctions.filter((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
);
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
}
@@ -310,15 +309,120 @@ function _functionEntry(
}
/**
* Client-mode api blocks become members of an `api` object in client scope.
* Blank out string/template literals and comments in a raw JS body, preserving
* length and newlines, so a scanner walking the result never mistakes text
* inside a string or comment for real code. Reuses the tokenizer's
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
* reimplementing them a second hand-rolled scanner is how apostrophes in
* prose used to swallow braces elsewhere in this codebase.
*/
function maskStringsAndComments(src: string): string {
let out = "";
let i = 0;
let atLineStart = true;
while (i < src.length) {
const c = src[i]!;
if (c === "\n") {
out += c;
atLineStart = true;
i++;
continue;
}
const skipped = skipLiteralOrComment(src, i, atLineStart);
if (skipped !== null) {
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
i = skipped;
atLineStart = false;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
out += c;
i++;
}
return out;
}
/**
* Block names the page's client functions actually call.
*
* A block's response and error bodies are page code. Emitting one the browser
* never calls would ship a server-only transform to every visitor and grow the
* bundle for nothing.
*/
function clientCalledApiNames(ast: PageAst): Set<string> {
const called = new Set<string>();
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
const masked = maskStringsAndComments(fn.body);
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
called.add(match[1]!);
}
}
return called;
}
/**
* Refuse to compile a client/shared function whose body references `api` in
* any form other than `api.<identifier>` e.g. `api["searchUsers"]()`, or
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
* above) can only see `api.<identifier>` calls; a dynamic or indirect
* reference is invisible to it, so the referenced block would be silently
* dropped from the browser bundle and the call would fail at runtime with
* "api.<name> is not a function". That failure direction is worse than a
* loud compile error, so it is caught here instead.
*
* Strings and comments are masked out first so `api` appearing in prose or in
* a quoted value never trips this check, and every `api.<identifier>` access
* is stripped before the standalone-word scan so a real, well-formed call
* never does either.
*/
function assertNoDynamicApiAccess(ast: PageAst): void {
// Only pages with a sectioned api block have anything at stake here: those
// blocks are emitted solely because usage detection saw `api.<name>`, so a
// dynamic reference this scan can't see is the one that silently drops a
// block from the bundle. A page with no api blocks at all may still declare
// an ordinary `state api` (see the B5 regression test) where a bare "api"
// identifier is just that state, not a missed block reference.
if (!ast.dataApis.some((block) => block.sections)) return;
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
const masked = maskStringsAndComments(fn.body);
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) =>
match.replace(/[^\n]/g, " "),
);
if (/\bapi\b/.test(withoutCalls)) {
throw new Error(
`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`,
);
}
}
}
/**
* A block is emitted into the browser module when it declares typed sections
* and a client function actually calls it. `hasClientApi` below must use this
* exact predicate so the `api` reserved-binding exclusion and the emitted
* object can never disagree.
*/
function isClientEmittedApiBlock(block: PageAst["dataApis"][number], called: Set<string>): boolean {
return Boolean(block.sections) && called.has(block.name);
}
/**
* Client-mode and client-called any-mode api blocks become members of an
* `api` object in client scope.
*
* Only the response and error bodies are emitted; the declared field types are
* type-only and are consumed by the types generator instead. Anything
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
*/
function apiBindings(ast: PageAst): string {
const called = clientCalledApiNames(ast);
const members = ast.dataApis
.filter((block) => block.mode === "client" && block.sections)
.filter((block) => isClientEmittedApiBlock(block, called))
.map((block) => {
const sections = block.sections!;
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
@@ -336,9 +440,8 @@ function apiBindings(ast: PageAst): string {
}
export function generateBrowserModule(ast: PageAst): string {
const functions = ast.runtimeFunctions.filter((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
);
assertNoDynamicApiAccess(ast);
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
const functionNames = functions.map((fn) => fn.name);
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
const selectedImports = selectedBrowserImports(ast, functions);
@@ -349,7 +452,9 @@ export function generateBrowserModule(ast: PageAst): string {
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const hasClientApi = ast.dataApis.some((block) =>
isClientEmittedApiBlock(block, clientCalledApiNames(ast)),
);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
+135 -102
View File
@@ -17,9 +17,16 @@
import { Buffer } from "node:buffer";
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import {
VOID_ELEMENTS,
type Attr,
type DataMode,
type PageAst,
type ViewNode,
eraseFunctionTypes,
runtimeTypeOf,
stripRuntimeFunctionModifiers,
} from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import {
@@ -560,29 +567,6 @@ function compileIfExpr(node: IfNode): string {
return "${" + expr + "}";
}
/**
* Collect every server-control expression in a view (recursively): `{#each}` list
* expressions and `{#if}` conditions. Used to wrn up raw SSR data consts.
*/
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
for (const node of nodes) {
if (node.type === "text") continue;
if (node.type === "each") {
out.push(node.list);
collectControlExprs(node.body, out);
collectControlExprs(node.empty, out);
} else if (node.type === "if") {
for (const b of node.branches) {
if (b.cond) out.push(b.cond);
collectControlExprs(b.body, out);
}
} else if (node.type === "element") {
collectControlExprs(node.children, out);
}
}
return out;
}
function renderNode(
node: ViewNode,
ssrBindings: SsrBinding[],
@@ -718,10 +702,14 @@ function renderNode(
);
}
const apiName = attrValue(node.attrs, "api");
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
if (apiName && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${apiName}"`);
const apiAttr = attrValue(node.attrs, "api");
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
if (apiAttr && !parsedApi) {
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
}
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
if (parsedApi && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
@@ -730,37 +718,32 @@ function renderNode(
const csrText = attrValue(node.attrs, "csrText");
const csrId =
apiBinding?.mode === "client"
? csrMarker(csrBindings, renderBinding(apiBinding))
: csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
// Void elements (<br>, <img>, …) have no closing tag and no children.
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
}
const inner =
apiBinding?.mode === "ssr"
? ssrMarker(ssrBindings, renderBinding(apiBinding))
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
path: apiRoutePath(ssrGet),
body: expressionBody(ssrText),
helpers: "",
})
: node.children
.map((child) =>
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive),
)
.join("");
const inner = apiBinding
? apiCallMarker(loops, parsedApi!.name, parsedApi!.args)
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
path: apiRoutePath(ssrGet),
body: expressionBody(ssrText),
helpers: "",
})
: node.children
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join("");
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
}
@@ -859,6 +842,34 @@ function renderNestedComponentInvocation(
);
}
/**
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
* so no new escaping or attribute-naming rules are introduced. The argument
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
* the argument expression (object literals, arrays, strings) are carried
* through untouched rather than truncated at the first `)`.
*/
function parseApiBinding(value: string): { name: string; args: string } | null {
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
if (!match) return null;
return { name: match[1]!, args: (match[2] ?? "").trim() };
}
/**
* Emit a render-time call into the server `api` object (Task 4's generated
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
* that the loop/expression-splicing mechanism swaps for the real `${}` code.
* This calls the same server `api.<name>()` member a `load`/action block would
* call -- it does not reimplement fetch/response handling -- so a block that is
* both render-bound and called from code runs its own call each time (no
* dedup is attempted; see apis-render-binding.test.ts).
*/
function apiCallMarker(loops: string[], name: string, args: string): string {
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
return `\x00WRNEACH${loops.length - 1}\x00`;
}
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
@@ -871,16 +882,6 @@ function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
return id;
}
function renderBinding(binding: NamedDataBinding): RenderBinding {
return {
method: binding.method,
path: binding.path,
body: binding.body,
helpers: binding.helpers,
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
};
}
function hasClientBehavior(nodes: ViewNode[]): boolean {
return nodes.some((node) => {
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
@@ -922,18 +923,6 @@ function dataBody(source: string): string {
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
}
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
return [
sharedHelpers,
...ast.modeFunctions
.filter((block) => block.mode === mode)
.map((block) => block.body.trim())
.filter(Boolean),
]
.filter(Boolean)
.join("\n\n");
}
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
const bindings = new Map<string, NamedDataBinding>();
@@ -956,13 +945,47 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
// legacy blocks and sectioned blocks without `error` keep failures
// propagating exactly as before.
...(errorSection ? { errorBody: errorSection } : {}),
helpers: modeHelpers(ast, block.mode, sharedHelpers),
helpers: sharedHelpers,
});
}
return bindings;
}
/**
* Server-side `api` object.
*
* The transport dispatches in-process, so a call from a load block or an action
* costs a function call rather than a network round trip. The request context
* comes from AsyncLocalStorage because `ctx` is not in scope everywhere server
* code runs.
*/
function serverApiBindings(ast: PageAst): string {
const members = ast.dataApis
.filter((block) => block.mode === "any")
.map((block) => {
const sections = block.sections!;
const response = sections.response.trim() || "return data;";
const error = sections.error.trim();
const failure = error
? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}`
: `throw err;`;
return ` ${JSON.stringify(block.name)}: async (input?: unknown) => {
const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext;
let data: any;
try {
data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input);
} catch (err) {
${failure}
}
${response}
}`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n};` : "";
}
function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
@@ -970,7 +993,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
}
type __WrnexusContext = import("@wrnexus/core").Context & {
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
localStorage?: unknown;
};
@@ -1017,13 +1040,27 @@ function __wrnexusPropAttr(
);
}
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
async function __wrnexusCallApi(
path: string,
method: string,
ctx: __WrnexusContext,
input?: unknown,
): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method);
return await ctx.__wrnexusCallApi(path, method, input);
}
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
const built = __wrnexusBuildApiRequest(path, method, input as Record<string, unknown> | undefined);
const url = new URL(built.url, ctx.req.url);
const headers = new Headers(ctx.req.headers);
if (built.contentType) headers.set("content-type", built.contentType);
const res = await fetch(
new Request(url, {
method,
headers,
...(built.body === undefined ? {} : { body: built.body }),
}),
);
const type = res.headers.get("content-type") || "";
if (!res.ok) {
const data = type.includes("application/json")
@@ -1310,9 +1347,7 @@ function hydrationAttribute(ast: PageAst): string {
function targetFunctions(ast: PageAst, target: "browser" | "server"): string {
const runtimes =
target === "browser"
? (["legacy", "client", "shared"] as const)
: (["legacy", "server", "shared"] as const);
target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const);
return ast.functions
.map((body) => stripRuntimeFunctionModifiers(body, [...runtimes]))
.map((body) => body.trim())
@@ -1426,6 +1461,12 @@ function generateInner(ast: PageAst): string {
);
}
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
const hasServerApis = ast.dataApis.some((block) => block.mode === "any");
if (hasServerApis) {
out.push(
`import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`,
);
}
const ssrBindings: SsrBinding[] = [];
const csrBindings: CsrBinding[] = [];
const helpers = targetFunctions(ast, "server");
@@ -1571,29 +1612,21 @@ function generateInner(ast: PageAst): string {
}
}
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
const loopConsts: string[] = [];
if (loops.length > 0) {
const lists = collectControlExprs(ast.view);
for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr") continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
const errorBodyProp = binding.errorBody
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
: "";
loopConsts.push(
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
);
}
}
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
if (needsSsrRuntime) {
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
if (needsRuntimeHelpers) {
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
out.push(ssrRuntimeSource());
out.push(
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
);
}
if (hasServerApis) {
out.push(serverApiBindings(ast));
}
if (needsSsrRuntime) {
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(
`export default async function ${ast.name}(ctx: __WrnexusContext) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Attr, PageAst, ViewNode } from "./parser.ts";
import type { Attr, PageAst, ViewNode } from "@wrnexus/syntax";
export class NativeCompileError extends Error {
constructor(message: string) {
-2
View File
@@ -1,2 +0,0 @@
/** @deprecated Import parser APIs from @wrnexus/syntax. */
export * from "@wrnexus/syntax/parser";
+3 -3
View File
@@ -25,7 +25,7 @@ function stableId(value: string): string {
*/
export function remotelyReferencedServerFunctions(ast: PageAst): Set<string> {
const browserSources = ast.runtimeFunctions
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
.filter((fn) => ["client", "shared"].includes(fn.runtime))
.map((fn) => fn.body);
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
if (hook !== "serverInit" && body) browserSources.push(body);
@@ -57,11 +57,11 @@ export function rpcManifest(ast: PageAst): RpcManifestEntry[] {
export function generateServerFunctionsModule(ast: PageAst): string {
const source = ast.functions
.map((body) => stripRuntimeFunctionModifiers(body, ["legacy", "server", "shared"]))
.map((body) => stripRuntimeFunctionModifiers(body, ["server", "shared"]))
.filter(Boolean)
.join("\n\n");
const names = ast.runtimeFunctions
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
.filter((fn) => ["server", "shared"].includes(fn.runtime))
.map((fn) => fn.name);
const manifest = rpcManifest(ast);
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
+2 -2
View File
@@ -175,7 +175,7 @@ export function generateStoreBrowserModule(ast: PageAst): string {
.join(",\n");
const groups = new Map<string, RuntimeFunctionDecl[]>();
for (const fn of ast.runtimeFunctions.filter((entry) =>
["client", "shared", "legacy"].includes(entry.runtime),
["client", "shared"].includes(entry.runtime),
)) {
const group = groups.get(fn.name) ?? [];
group.push(fn);
@@ -296,7 +296,7 @@ function __create(definition) {
Object.keys(actions).forEach(function (name) { delete actions[name]; });
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
const name = pair[0], candidates = pair[1];
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; });
if (!selected) return;
actions[name] = async function () {
const args = Array.prototype.slice.call(arguments);
-2
View File
@@ -1,2 +0,0 @@
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
export * from "@wrnexus/syntax/tokenizer";
+1 -1
View File
@@ -47,7 +47,7 @@ export function generateDeclarations(ast: PageAst): string {
)
.join("\n");
const clientFunctions = ast.runtimeFunctions
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared")
.map(
(fn) =>
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
-2
View File
@@ -1,2 +0,0 @@
/** @deprecated Import language type utilities from @wrnexus/syntax. */
export * from "@wrnexus/syntax/types";
@@ -1,285 +0,0 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function browserModule(inner: string): string {
return generateTargets(
parse(`page Repro {
client {
${inner}
}
functions {
client async function run(): Promise<void> {
const users = await api.searchUsers({ name: "Ajay" })
console.log(users)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
const BLOCK = ` api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
}
}
response {
return data.users
}
error {
return []
}
}`;
test("emits an api member that calls the transport with the block's path and method", () => {
const generated = browserModule(BLOCK);
expect(generated).toContain("const api =");
expect(generated).toContain("searchUsers");
expect(generated).toContain('"/api/users"');
expect(generated).toContain('"POST"');
});
test("declared field types never reach the browser module", () => {
// The artifact is written as .mjs and parsed as JavaScript.
const generated = browserModule(BLOCK);
expect(generated).not.toContain("name?: string");
expect(generated).not.toContain("age?: number");
});
test("the emitted module is valid JavaScript", () => {
const generated = browserModule(BLOCK);
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a block without an error section still emits its response body", () => {
const generated = browserModule(` api plainUsers GET /api/users {
request {
parameters {
team: string
}
}
response {
return data.users
}
}`);
expect(generated).toContain("plainUsers");
expect(generated).toContain("data.users");
});
test("type annotations in response/error bodies are erased before emission (B4)", () => {
// Every other browser-bound body in the repo passes through eraseFunctionTypes
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
// store-codegen.ts); response/error bodies must too, for the same reason:
// eraseFunctionTypes strips function-signature annotations (params, return
// type, typed catch clauses) so a locally-declared helper function inside a
// response/error body no longer ships raw TypeScript into the .mjs artifact.
const generated = browserModule(` api searchUsers POST /api/users {
request {
body {
name?: string
}
}
response {
function pick(list: string[]): string[] { return list }
return pick(data.users)
}
error {
function describe(e: unknown): string { return String(e) }
return describe(error)
}
}`);
expect(generated).not.toContain("list: string[]");
expect(generated).not.toContain("): string[] {");
expect(generated).not.toContain("e: unknown");
expect(generated).not.toContain("): string {");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a page with state api and no client api blocks still reads that state (B5)", () => {
// "api" is normally excluded from state/prop destructuring because the
// emitted `const api = {...}` binding would shadow it -- but that binding
// only exists when the page has client-mode api blocks. Without one, the
// exclusion left `api` completely undeclared: a ReferenceError.
const generated = generateTargets(
parse(`page Repro {
state {
api = "hello"
}
functions {
client function run(): void {
console.log(api)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(generated).toContain("context.state");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
/**
* Builds a browser module whose `run()` function calls api.searchUsers and
* reports the outcome through `output.report(...)` so the test can observe
* whether the call resolved or rejected without reaching into codegen
* internals.
*/
function reportingBrowserModule(apiBlock: string): string {
return generateTargets(
parse(`page Repro {
client {
${apiBlock}
}
outputs {
report(payload: any)
}
functions {
client async function run(): Promise<void> {
try {
const users = await api.searchUsers({ name: "Ajay" })
output.report({ ok: true, users })
} catch (e) {
output.report({ ok: false, message: String(e && e.message || e) })
}
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
async function importBrowserModule(source: string): Promise<any> {
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.mjs");
writeFileSync(file, source);
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
}
test("a response body error is not swallowed by the error section (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users.missing.length
}
error {
return []
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => ({ users: [] }),
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
// The error section's own fallback ("[]" / an empty array) must not have
// been what the caller observed -- a bug in the response body is a
// rejection, not a silently-returned fallback value.
expect(reports[0]).not.toEqual({ ok: true, users: [] });
});
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users
}
error {
return ["fallback"]
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => {
throw Object.assign(new Error("transport failed"), { status: 500 });
},
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
});
test("a state field named api does not collide with the emitted api object", () => {
const generated = generateTargets(
parse(`page Repro {
state {
api = ""
}
client {
${BLOCK}
}
functions {
client async function run(): Promise<void> {
const users = await api.searchUsers({ name: "Ajay" })
console.log(users)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
@@ -1,282 +0,0 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
// unrelated TypeScript version that doesn't understand this repo's tsconfig
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
// which uses this same local binary.
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
// file that's actually invoked (our temp one), not the base file — so the
// ambient `bun` types need an explicit path back to the repo's node_modules.
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/**
* Runs the real TypeScript compiler over a generated server module. Proves
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
* module) actually type-checks string-containment assertions alone can't
* catch a declared type that omits a field every emitted object literal has.
*/
function typecheckGenerated(source: string): { ok: boolean; output: string } {
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
roots.push(root);
const file = join(root, "page.ts");
writeFileSync(file, source);
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
// checks the one file we care about instead of hand-duplicating the whole
// compiler configuration (and drifting from it over time).
writeFileSync(
join(root, "tsconfig.json"),
JSON.stringify({
extends: ROOT_TSCONFIG,
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
include: ["page.ts"],
}),
);
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
});
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
return { ok: result.exitCode === 0, output };
}
function serverModule(inner: string): string {
return generate(
parse(`page Repro {
ssr {
${inner}
}
view { <main><p api="ssrUsers">loading</p></main> }
}
`),
);
}
test("a sectioned ssr block binds the payload to data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).toContain("data.users.length");
});
test("a legacy ssr block is unchanged", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
return users.length
}`);
expect(generated).toContain("users.length");
});
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return message + status + data
}
}`);
expect(generated).toContain('"errorBody"');
expect(generated).toContain("return message + status + data");
expect(generated).toContain("const status = $status");
expect(generated).toContain("const message = $message");
expect(generated).toContain("const data = $data");
expect(generated).toContain("__wrnexusEvalError");
});
test("an ssr block without an error section emits no catch entry for that binding", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).not.toContain('"errorBody"');
});
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return message + status + data
}
}`);
const { ok, output } = typecheckGenerated(generated);
expect(output.trim()).toBe("");
expect(ok).toBe(true);
});
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block's response body error is not swallowed by the error section", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.missing.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => ({ users: [] }),
}),
).rejects.toThrow();
});
test("an ssr block still runs the error body on a genuine transport failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
});

Some files were not shown because too many files have changed in this diff Show More