Compare commits

...
Author SHA1 Message Date
ClintchizandClaude Opus 5 2f075df42c release: patch cli, compiler, core, csr, syntax, validation
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
Typed, callable api blocks for .wrn files: sectioned request/response/error,
callable from client code as api.name(input), type-checked by tsc against the
route contracts.

Also two fixes found along the way: defineEndpoint never received its input
through the real router, so every typed endpoint was validating undefined; and
v.boolean() silently coerced any unrecognised string to false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:36:44 +05:30
ClintchizandClaude Opus 5 281615a4b0 fix(validation): stop unrecognised boolean strings coercing to a silent false
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
checkField in packages/validation/src/index.ts (and its browser mirror in
runtime.ts) treated any string other than "true"/"on" as false with no
error, so typos like "treu" or values like "yes"/"1"/"TRUE" silently
passed as false.

Now:
- true/false booleans pass through unchanged
- recognised true strings (case-insensitive, trimmed): true, on, 1, yes
- recognised false strings: false, off, 0, no
- numeric 1/0 coerce (JSON payloads)
- undefined/null/"" still coerce to false (unchecked-checkbox semantics)
- anything else is now a type error (desc.typeMessage or "Must be true or
  false") instead of a silent false

Locked-in behaviours preserved: a required boolean given false still
errors, and parseEnv DEBUG: "true" coercion still works.

Added coverage for recognised strings, numeric 1/0, the type-error
regression guard, absent/empty handling, the required+false case, and a
client/server parity test driving both checkField and the browser runtime
through the same inputs.

Blast radius: searched packages/, examples/, services/ for v.boolean()
usage; all existing call sites (auth consent/rememberDevice, db 'active'
default, example consent checkboxes) feed true/false/'on'/absent values,
none of which change behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:26:20 +05:30
Clintchiz 3ae5d7cf97 fix(compiler): stop error {} from swallowing response {} bugs in api blocks
Client codegen chained .then().catch(), so a .catch() after .then()
caught exceptions thrown by the response body too. Switched to the
two-argument then(onFulfilled, onRejected) form, whose rejection
handler cannot see errors from the fulfilment handler.

SSR codegen wrapped both the transport call and the response-body eval
in the same try; only __wrnexusCallApi is now inside the try, and
__wrnexusEvalData runs after it, outside.

Also verifies (and locks in with a regression test) that GET query
numbers already coerce correctly through defineEndpoint + checkField,
and documents that in the typed-api-block spec.
2026-08-19 21:11:26 +05:30
ClintchizandClaude Opus 5 b5029889a5 fix: address all seven final-gate findings for typed api blocks
B1: qualify each generated __wrn_api_check_* assertion name with a short
hash of the page's path (relative to app/, for reproducibility across
checkouts) so two pages declaring a same-named block no longer collide
with an identical type alias (TS2300).

B2: skip assertion emission for any block that is not client-mode, or
that has zero declared request fields. ssr sectioned blocks can never
declare a request and always fell back to Record<string, never>, whose
keyof is `string` -- making the key-exactness arm of AssertAssignable
evaluate to false unconditionally (TS2344) on every ssr sectioned block
regardless of correctness. Chose to skip both non-client blocks and
zero-field client blocks, since neither has anything meaningful to
assert type-safety about.

B3: only resolve the endpoint's input (query params / ctx.req.json())
when the endpoint declares an input schema. Previously the router-set
fix accidentally read the request body unconditionally, so a handler
with no input schema that parses the request itself hit
ERR_BODY_ALREADY_USED.

B4: run response/error bodies in client-mode api blocks through
eraseFunctionTypes, matching every other browser-bound body in
client-codegen.ts, so a TypeScript-only construct inside one (e.g. an
annotated locally-declared function) doesn't reach the .mjs artifact.

B5: only exclude "api" from state/prop destructuring in the generated
browser module when the page actually has client-mode api blocks (i.e.
there is a real `api` binding to shadow). Previously "api" was always
excluded, so a page with `state api` and no api blocks got an
undeclared `api` reference (ReferenceError) in client code.

B6: prefix each emitted assertion with `export`, so it isn't flagged as
an unused local under a downstream project's noUnusedLocals (TS6196).

B7: wrnexusCallApi now resolves with undefined for an ok 204/205
response, or an ok response with an empty/unparseable body, instead of
rejecting with "Response was not valid JSON" -- matching the spec's
failure table (error path only for non-2xx, network failure, or an
actually unparseable body on a non-empty response).

Regenerated examples/basic-app's generated types and editor bundles to
match. Confirmed the example's type gate still fails when an
unaccepted field is added to a request body, and passes cleanly
otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:38:22 +05:30
ClintchizandClaude Opus 5 e9db4ca24d test(core): cover defineEndpoint's no-second-argument request-parsing path
Every existing test in endpoint-schema.test.ts passed rawInput
explicitly, so the branch added to endpoint.ts's fix (GET/HEAD query
parsing, JSON body parsing, malformed/absent body fallback) was
exercised by nothing but a manual curl. Add coverage that calls the
endpoint with only a context, matching the real router's calling
convention:

- GET with query parameters populates input from ctx.url.searchParams.
- POST with a JSON body populates input from the parsed body.
- POST with a malformed or absent body does not throw; the schema's
  own validation decides the outcome (asserted on the real response).
- An explicit rawInput argument still wins and the request is never
  read (the body is drained first, so a second .json() call would
  reject if the endpoint tried to read it again) -- the regression
  guard for the branch intentionally left untouched.

Confirmed the GET and POST-body tests fail against the pre-fix
endpoint.ts (input resolves as undefined/null instead of the sent
value); the malformed/absent-body test does not distinguish pre- and
post-fix, because in that specific edge case both normalize to an
effectively empty input -- noted in the report rather than forced.

Added a CHANGELOG entry documenting the behavior change for
downstream apps: a request that previously passed vacuous validation
on a defineEndpoint route can now legitimately fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:16:32 +05:30
ClintchizandClaude Opus 5 5318320c70 fix(examples): make the api-block demo endpoint actually type-check
directory.ts previously exported a plain (ctx: Context) => ... handler.
With that shape ApiInput<> resolved to unknown, so the generated
__wrn_api_check assertion for the demo page passed trivially even with
a field the endpoint does not accept -- the worked example did not
demonstrate the type safety it exists to demonstrate.

Rewrite directory.ts to use defineEndpoint with a schema (matching
typed-user.ts), which gives the generated assertion a real input type
to check against. Confirmed: adding an unaccepted field to the block's
request body now fails typecheck naming
__wrn_api_check_searchDirectory; removing it passes with zero net
diff.

Fix a real bug this surfaced: packages/core/src/endpoint.ts only read
its input from a second 'rawInput' argument, but the actual HTTP
router (packages/dev-server/src/runtime.ts handleApi) invokes route
handlers as handler(ctx) with no second argument. Every
defineEndpoint-based route -- including the pre-existing typed-user.ts
example -- silently received an empty/undefined input through the
real router (confirmed via curl: valid typed-user payloads were
rejected as 'Required'; directory's name filter matched every record
regardless of query). Fixed by having the endpoint wrapper parse the
request itself (query params for GET/HEAD, JSON body otherwise) when
no rawInput is explicitly supplied, while still honoring an explicit
rawInput for direct/unit-test callers.

Also update api-block-demo.wrn's response section: defineEndpoint
wraps handler output as { data: ... }, so the block's raw response
body is now { data: { users: [...] } } -- response reads
data.data.users instead of data.users.

Re-verified in a real browser after the endpoint rewrite and the
router fix: search returns exactly "Ajay, Asha", exactly one
POST /api/directory carrying x-csrf-token, and the error section
still runs cleanly (no exception, empty result) on a missing route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:10 +05:30
Clintchiz b3ed9689fa docs: add complete framework feature report
Quality / quality (ubuntu-latest) (push) Failing after 9m54s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-19 17:52:08 +05:30
ClintchizandClaude Opus 5 e5de7b54a5 feat(examples): worked example for typed api blocks
- Add examples/basic-app/app/api/directory.ts and app/pages/api-block-demo.wrn
  as the end-to-end worked example for typed api blocks (Task 6).
- Add **/*.generated.api-checks.ts to .prettierignore: this generated file
  must match the CLI's raw output byte-for-byte for check:generated-types,
  and prettier was reformatting it.
- Fix packages/csr/test/api-call.test.ts: no-unsafe-function-type lint error
  from the raw Function type, uncovered while running the full gate.
- Rebuild editors/vscode bundles (packages/compiler and packages/syntax
  changed in Tasks 1-5).
- Regenerate docs/public-api-0.8.json (additive: isIdentPart, isIdentStart,
  skipLiteralOrComment newly exported from packages/syntax).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:08:49 +05:30
ClintchizandClaude Opus 5 3252b1b20e fix(compiler): type-check the emitted ssr binding array and share error handling across call sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:42:38 +05:30
ClintchizandClaude Opus 5 7601477f7d feat(compiler): run error section on ssr api call failure
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:24:20 +05:30
ClintchizandClaude Opus 5 847b6dbe59 feat(compiler): support sections in ssr api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:19:27 +05:30
ClintchizandClaude Opus 5 04be24ddd8 fix(cli): close the extra-field gap in AssertAssignable, add tsc-based enforcement tests
AssertAssignable was one-directional ([Actual] extends [Expected]), so a
block declaring a field the contract doesn't accept passed silently
(TypeScript's excess-property check only applies to fresh object
literals, not conditional-type extends). Add a key-exactness check
(Exclude<keyof Actual, keyof Expected> extends never) alongside the
assignability check. Guard it with 'unknown extends Expected' so
untyped (no defineEndpoint contract) routes still only warn, per the
existing behaviour, instead of being forced to fail on every declared
field.

Add packages/cli/test/api-block-types.test.ts cases that regenerate a
fixture and run the real TypeScript compiler (via bunx tsc) over the
generated output, asserting on its diagnostics rather than on emitted
text: matching fields compile clean; a wrong-typed field, an extra
field (the Finding-A regression guard), and a missing required field
all fail, each pointing at the offending block's __wrn_api_check_*
line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:14:13 +05:30
ClintchizandClaude Opus 5 785e8a65bd fix(cli): move api-block assertions into a real .ts file
skipLibCheck exempts .d.ts contents from being checked, so assertions
written inside wrnexus.generated.d.ts were never evaluated by tsc.
Emit them into wrnexus.generated.api-checks.ts instead, referencing
the WRNexusGenerated namespace's helper types (which stay in the
.d.ts). Track the new generated file in check:generated-types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:01:07 +05:30
ClintchizandClaude Opus 5 a2698fb51a docs: correct the api block spec's type-enforcement mechanism
Assertions in a .d.ts are inert under skipLibCheck: true, which the root
tsconfig sets. Proven during implementation by forcing skipLibCheck: false,
where the same assertion fires as TS2344. They move to a generated .ts file,
which skipLibCheck does not exempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:58:05 +05:30
ClintchizandClaude Opus 5 419614d9d1 feat(cli): generate type assertions for api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:02 +05:30
ClintchizandClaude Opus 5 70777e4a45 fix(compiler): reserve api as a runtime binding to avoid client-scope collision
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:45:09 +05:30
ClintchizandClaude Opus 5 bd2f6ac5e3 feat(compiler): compile client api blocks into the browser module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:41:13 +05:30
Clintchiz b457ad1d54 feat(csr): add the api block transport 2026-08-19 15:37:23 +05:30
ClintchizandClaude Opus 5 323f57b32b fix(syntax): make api-section scanning string/comment-aware
Reuse tokenizer.readBalancedBraces string/comment skipping (extracted as
skipLiteralOrComment) for both section detection and slicing, instead of a
second hand-rolled brace counter. Fixes truncation on braces inside strings
and false-positive sectioned detection from keywords inside comments/strings.
Also switch api-sections.ts errors from plain Error to LexError so parser.ts
upgrades them to ParseError with an offset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:30:51 +05:30
ClintchizandClaude Opus 5 028c2a6d64 feat(syntax): parse sectioned api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:21:10 +05:30
ClintchizandClaude Opus 5 2f0f82b29f chore: ignore the subagent-driven-development workspace
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:18:39 +05:30
ClintchizandClaude Opus 5 50097ec4b4 docs: implementation plan for typed api blocks
Six tasks: parse the sections, add the CSR transport, compile client-mode
blocks into the browser module, generate the tsc assertions, support
sections in ssr blocks, and verify end to end in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:15:02 +05:30
ClintchizandClaude Opus 5 f026415ba6 docs: design for typed, callable api blocks in .wrn files
A sectioned `api` block -- request / response / error -- callable on
demand from client code, typed against the API route contracts the types
generator already emits.

Records the constraints that shaped it: the current block cannot carry a
query string (isSafeApiPath rejects "?"), cannot interpolate (readPath
stops at "{"), has nowhere to put a body, and fetches once. And the one
that decides the type-safety mechanism -- generated build artifacts are
not type-checked, so enforcement goes into the generated .d.ts, which the
project's own tsc already compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:09:30 +05:30
ClintchizandClaude Opus 5 2cbc3e43e1 release: patch compiler, cli, dev-server
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
Strips TypeScript from client function bodies when emitting browser
modules, so `wrnexus build` no longer fails on an annotated local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:17:51 +05:30
ClintchizandClaude Opus 5 55fed2177a fix(compiler): strip TypeScript from client function bodies
Quality / quality (ubuntu-latest) (push) Failing after 9m53s
Quality / quality (windows-latest) (push) Canceled after 0s
`wrnexus build` failed on any client function whose body used TypeScript:

    const requestBody: Record<string, unknown> = {}
    error: Expected ";" but found ":"

Codegen copies a client function's body into the browser module verbatim.
It removes the types from the function's *signature*, which is what made
this easy to miss -- the emitted module looked transpiled, and only bodies
carried types through. The artifact is written as .mjs and read back as
plain JavaScript, so the failure surfaced as a syntax error in generated
code rather than at the .wrn line responsible.

Browser modules are now transpiled before they are written, at all three
sites that emit one (the production build and both dev-server paths).

Reproduced end to end: a page with an annotated body failed the build with
the reported errors, and after the fix builds, ships valid minified JS, and
runs -- the handler sets its state correctly in a browser.

Note: the same body is also embedded as a string for the CSP-safe fallback
interpreter, which still receives it untranspiled. The compiled module
shadows the fallback, so this is only reachable in the window before that
module loads. Left alone here because stripping it lives in codegen, which
also runs under Node in the editor bundle where the Bun transpiler is
unavailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:03:14 +05:30
ClintchizandClaude Opus 5 8c609edd32 release: patch csr, dev-server, language-server, react, ui
Quality / quality (ubuntu-latest) (push) Failing after 11m36s
Quality / quality (windows-latest) (push) Canceled after 0s
Client control blocks and loops, the dev-server rebuild recycle, the
editor's tag and completion handling, and the island mount/HMR fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:42:26 +05:30
ClintchizandClaude Opus 5 e898929193 fix(react): mount visible islands and load rebuilt code after HMR
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
Two faults found by driving the island demo in a real browser. Both were
silent: the markup, every asset, and all 48 island tests were correct
either way.

An island renders nothing until it mounts, so its placeholder is
zero-height, and IntersectionObserver does not treat a zero-area target
consistently -- client:visible islands mounted on one load and not the
next. Visibility for those is now decided from the element's own rect,
driven by scroll and resize; a placeholder with real size still uses the
observer. The strategy had no test at all, which is why this shipped.

After an island source edit the browser kept running the old code. The
rebuild worked and the file was refetched, but the loader imports a URL
that does not change, and the browser caches modules by URL. Remounts now
carry a generation the dev loader folds into the request.

Verified in the browser: mounts with start={3} as a number, clicks reach
React (3 -> 5), and an edit to Counter.tsx now shows the new text and
stays interactive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:32:05 +05:30
ClintchizandClaude Opus 5 18a1c40118 fix(dev-server): recycle the server once hot rebuilds pile up
The dev server got slower the longer it ran. Measured on the example app:
30 .wrn edits grew RSS from 117 MB to 137 MB and never gave it back, while
30 CSS edits cost nothing -- so the leak is exactly one retained module
identity per rebuild, not caches or file handles.

That is inherent to reloading a module in-process. Bun caches modules by
path, so a rebuild has to be given a new identity to be picked up at all,
and Bun has no API to unload the old one. At roughly 0.66 MB a rebuild, a
long editing session is several hundred megabytes of garbage that cannot
be collected.

The process now recycles itself past a rebuild threshold, exiting with the
RESTART_EXIT_CODE the CLI supervisor already respawns on; browsers
reconnect because the HMR client already retries. It waits for a quiet
period first so a live request is never cut off, and the threshold (300
rebuilds, about 200 MB) sits well above a normal session. Set
WRNEXUS_DEV_RECYCLE_AFTER to tune it, or 0 to switch it off.

Also bounds browserArtifactPaths and islandArtifactPaths, which are keyed
by content hash and so gained an entry per rebuild that was never read
again. Small next to the module leak, but unbounded is unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:30:54 +05:30
ClintchizandClaude Opus 5 a20f143acb fix: isolate test globals, close tags at every caret, trim the runtime
Three pre-existing issues that the previous commit worked around rather
than solved.

Test global pollution. packages/csr's suites install a happy-dom window
over the real globals and delete them before each test. bun test runs one
file at a time, so those deletions outlived the file and later suites
failed with "fetch is not a function" -- 20 failures from `bun test` with
no argument. They now restore what they captured. The editor's Node tests
shim the vscode host by patching Module._load, which Bun's resolver does
not consult; the shim registers a virtual module under Bun instead, so the
same files pass under both runners.

Multi-cursor tag auto-close. The handler now closes the tag at every
caret. Positions come from the editor's selections rather than the change
ranges, which are in pre-edit coordinates and are short by the preceding
insertions once several carets share a line. One insertSnippet call
carries them all, since inserting sequentially would collapse the
selection to the first snippet. Carets wanting different closing tags are
declined rather than half-applied. Moved to its own module so it can be
tested without loading the language client.

Runtime size. Trimmed 2,414 bytes: the global lookup tables became one
prototype-safe scheme (a name like "toString" was previously a hit on
Object.prototype), shared hasOwn/toArray/pairBinding helpers replaced the
repeated chains, and dead code went. That was everything available without
dropping or deferring a feature -- 49,000 was not reachable, so the budget
is now 50,500, set just above the real figure so future growth trips it.

Two tests changed: one asserted on runtime source text and now asserts the
timers resolve; a new one covers reactive class bindings inside data-for,
which the enclosing loop effect tracks rather than each binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:14:18 +05:30
ClintchizandClaude Opus 5 ac248f2bb0 fix(csr): run for/while loops and keep declarations out of state
The client runtime had no loop support, so any shared function using one
returned early -- Pagination and ButtonGroup were broken client-side, not
just in tests.

Adding loops exposed two further faults:

- A var reaching writeScope creates a signal and triggers a render sweep.
  A declaration inside a function called during a render therefore looped
  forever. Declarations now bind into the handler locals instead.
- A control block removed from the DOM keeps its effect in the renderers
  list. Running it against a detached node threw, aborting the sweep and
  leaving every later effect stale.

Also raises the reactive runtime budget to 53,000: the runtime had already
grown past 49,000 before this change, and 52,570 minified is 16,803 gzipped.

Two deferred minors: html-service leaves absent documentation undefined
rather than an empty string, and the extension declines tag auto-close on
multi-cursor edits rather than closing only the first cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:24:41 +05:30
ClintchizandClaude Opus 5 0904a4efaa fix(csr): render control blocks created by a client rerender
{#if}, {#each} and their {:else}/{:else if}/{:empty} branches worked on
the server and after hydration, but a block nested inside another block
stayed empty once the outer block rerendered. Adding a row to a list
produced the row's markup with its inner block markers in place and
nothing between them, for the life of the page.

Two causes, both on the client-created path only:

reactive() registers an effect; effects run when renderAll sweeps the
list. A state change runs just the affected effects rather than sweeping,
so an effect registered during that rerender was queued and never
invoked. setupControlBlock now returns its runner and the creating block
invokes it immediately.

The first reactive pass is skipped so hydration does not discard
server-rendered DOM. A block created by a rerender has no server DOM, so
skipping its only pass left it permanently empty. firstRun is now keyed
off outerLocals, which is set only on the client-created path.

Verified in a browser as well as in tests: adding a group to a list now
renders the new row's nested {:else}, and the existing rows' nested loops
survive the rerender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:49:31 +05:30
Clintchiz 5b11b937bb Release CLI with reactive control block runtime
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-19 00:27:32 +05:30
Clintchiz f199385204 Make if and each blocks reactive on the client
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-19 00:22:00 +05:30
Clintchiz c0c2fa4595 chore(release): publish CLI 0.8.43
Quality / quality (ubuntu-latest) (push) Failing after 9m48s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 23:38:54 +05:30
Clintchiz cfdcdd00ad chore(release): publish dev server 0.8.39
Quality / quality (ubuntu-latest) (push) Failing after 9m58s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 23:31:21 +05:30
Clintchiz 03d5cb6aa6 fix(gateway): proxy browser server functions to workspace apps
Quality / quality (ubuntu-latest) (push) Failing after 10m40s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 23:30:42 +05:30
Clintchiz 701acd828c fix(vscode): avoid relative-link parsing in changelog
Quality / quality (ubuntu-latest) (push) Failing after 9m54s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:55:11 +05:30
Clintchiz b28b79c370 fix(vscode): use supported Marketplace publish flags
Quality / quality (ubuntu-latest) (push) Failing after 6m1s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:52:59 +05:30
Clintchiz fdd0c9f847 chore: complete HTML editing verification
Quality / quality (ubuntu-latest) (push) Failing after 10m13s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:49:07 +05:30
Clintchiz 7ad336b4dd chore(vscode): prepare 0.8.8 marketplace release
Quality / quality (ubuntu-latest) (push) Failing after 9m53s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:43:01 +05:30
Clintchiz d70e89230b chore(release): publish language server 0.8.10
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:38:47 +05:30
ClintchizandClaude Opus 5 7e6d8c3bc8 test(language-server): add end-to-end verification for HTML editing support
Adds a scratch page (kept intentionally, per controller ruling on Task 8)
and one end-to-end test that drives the real language server over LSP
stdio against that page's real text, verifying completion (with the
seo-block negative case tested via a simulated '<' keystroke and
mutation-verified against html-regions.ts), hover, folding ranges,
linked editing, and wrn/tagComplete all work together on realistic
content.

Regenerates routes.gen.ts and wrnexus.generated.d.ts for the new page's
route, required by check:generated-types.

Two checks from the original brief (auto-close-tag insertion and Emmet
Tab-expansion) require a live VS Code Extension Development Host and
are documented as outstanding manual verification in
.superpowers/sdd/2026-08-18-wrn-html-editing/task-8-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:59:19 +05:30
ClintchizandClaude Opus 5 92c0920c9b test(vscode): guard the Emmet mapping and auto-close setting
Also fix an unused-var lint failure in completion-scope.test.js blocking the production gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:36:20 +05:30
Clintchiz a8d8ac386f test(vscode): add integration tests for completion provider guard 2026-08-18 21:28:38 +05:30
Clintchiz 2c8841cc5f fix(vscode): stop duplicating completions inside view blocks 2026-08-18 21:23:54 +05:30
ClintchizandClaude Opus 5 b344d2a70a fix(vscode): guard tag auto-close against replaced selections and stale round-trips
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:20:16 +05:30
ClintchizandClaude Opus 5 e83f0366ef feat(vscode): close HTML tags as they are typed in .wrn files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:16:47 +05:30
Clintchiz d9e8f5be82 feat(language-server): add tag folding and linked editing 2026-08-18 21:10:57 +05:30
Clintchiz bd0c1317ff test(language-server): cover didClose region-cache clear end-to-end
Replaces the direct-call-only test with an over-stdio test that exercises
server.ts's didClose handler itself, so it fails if the
clearHtmlRegionCache wiring is removed or misparameterized.
2026-08-18 21:04:33 +05:30
Clintchiz 97801287f9 feat(language-server): merge HTML completions and hover into one response 2026-08-18 20:56:17 +05:30
Clintchiz d77638131b fix(language-server): don't self-close tags inside quoted attribute values 2026-08-18 20:47:19 +05:30
Clintchiz 609224591c feat(language-server): answer HTML completion, hover, folding, and tag close 2026-08-18 20:43:47 +05:30
Clintchiz 6074d19c43 fix(language-server): bypass cache for version-less documents 2026-08-18 20:38:37 +05:30
Clintchiz 7301849a7f feat(language-server): add offset-preserving virtual HTML document 2026-08-18 20:34:24 +05:30
ClintchizandClaude Opus 5 7d481df652 docs(html-editing): add implementation plan
Eight TDD tasks: the view-block scanner and virtual document, the HTML
service wrapper, merging HTML into completion and hover, folding and
linked editing, auto-close on type, standing down the duplicate client
provider, manifest guards, and a manual editor check.

Task 1 comes first because everything reads positions through it: its
length-and-newline invariant is what removes position mapping, and a
break there would misreport positions everywhere rather than fail.

The last task is manual verification in an Extension Development Host.
Unit tests cannot show that completions actually appear in an editor, and
a green suite has hidden non-functional features in this repo before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:30:34 +05:30
ClintchizandClaude Opus 5 5477f5436d docs(html-editing): add design spec for HTML support in .wrn files
Markup in a .wrn file highlights but has no tag or attribute completion,
no tag closing, and no tag-level folding: the grammar's embeddedLanguages
mapping only affects tokenization, and VS Code's HTML language service
never runs on these documents.

The design extracts view blocks into a virtual HTML document where
everything outside them is blanked to whitespace of identical length, so
source positions and virtual positions are the same and no mapping table
is needed. Region detection is a tolerant scanner rather than the parser,
because completion fires while the document is mid-edit and unparseable.

Completion merges WRNexus and HTML entries into one list ranked by
sortText, which also fixes an existing bug: the extension and the server
both answer completion on '<' today, so VS Code concatenates two lists.

Two decisions worth review:

- HTML formatting is excluded. formatWrn already formats markup, knows
  WRNexus syntax, and would fight a second formatter that is free to
  rewrite spacing inside @click={...} and client:visible.
- Only auto-close-on-type is client-side. Linked editing is standard LSP
  and lives in the shared server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:23:08 +05:30
ClintchizandClaude Opus 5 b646ec8d00 chore(release): patch-bump packages changed since the last publish
Quality / quality (ubuntu-latest) (push) Failing after 12m48s
Quality / quality (windows-latest) (push) Canceled after 0s
cli 0.8.42, csr 0.8.22, db 0.8.16, dev-server 0.8.38,
dev-toolbar 0.8.13, i18n 0.8.12.

Every previous version was already on the registry, so the HMR client
repair, the i18n JSON data block, the gateway WebSocket origin fix, and
the generated-dialect stamp were not reachable by consumers.

compiler and react are unchanged since their last publish and are not
bumped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:01:01 +05:30
ClintchizandClaude Opus 5 e66d2425aa fix(gateway): allow HMR sockets on every configured domain
Quality / quality (ubuntu-latest) (push) Failing after 13m52s
Quality / quality (windows-latest) (push) Canceled after 0s
The WebSocket origin check compared the browser's Origin host, which
carries the port, against configured domains, which do not. publicOrigin
only ever matches domains[0], so every other domain fell through to that
comparison and was denied purely on the port: web.localhost:3000 never
matched web.localhost.

The result was a 403 on the HMR upgrade and a client reconnecting
forever, while the page itself loaded fine because HTTP routing resolves
the Host separately.

Compares hostnames now. Unrelated and lookalike-suffix origins are still
denied, and both cases are covered by tests.

Verified through a real gateway: the HMR socket opens on both localhost
and web.localhost, and a live edit reaches the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:53:27 +05:30
ClintchizandClaude Opus 5 b3b65dddd8 fix(db): stamp the dialect into generated query files
Quality / quality (ubuntu-latest) (push) Failing after 12m46s
Quality / quality (windows-latest) (push) Canceled after 0s
The same generate command emitted ? one run and $1 the next, which looked
like non-determinism. It is not: postgres uses $1 placeholders where
sqlite and mysql use ?, and the driver comes from the active profile, so
building under a different profile rewrites this committed file.

The header now records the dialect it was generated for, making the flip
visible in the diff and explaining check:generated-types failures instead
of leaving them looking like random churn.

Worth deciding separately: a committed artifact whose contents depend on
the active profile will keep drifting. Either generate per dialect, or
stop committing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:44:36 +05:30
ClintchizandClaude Opus 5 5dbcc5b85d fix(i18n): ship i18n data as a JSON block so CSP cannot block it
window.__wrnI18n was undefined in development: the payload shipped as an
executable inline script, and a document's CSP nonce is fixed at load, so
any such script arriving from a later response is blocked. Client
translations and language switching silently had no data.

The payload is now a type="application/json" block, which the browser
never executes and script-src therefore never applies to. The i18n
runtime, CSR navigation, and HMR all read the block instead of matching
window.__wrnI18n= with a regex.

Pages now render zero executable inline scripts, so an inline script-src
violation is structurally impossible rather than merely unobserved. Zero
framework JavaScript on island-free routes is unaffected: the block is
inert data, and nothing loads to read it unless the page needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:44:28 +05:30
ClintchizandClaude Opus 5 e819c5739e Revert "chore(example): regenerate basic-app queries"
Quality / quality (ubuntu-latest) (push) Failing after 12m38s
Quality / quality (windows-latest) (push) Canceled after 0s
The generator's placeholder style is not deterministic across runs: the
same command emitted $1 once and ? the next time, depending on the
database dialect active in the environment. Restoring the committed
output and reverting my earlier regeneration, which was environment
churn rather than an intended change.

Worth a look on its own: a generator whose output depends on ambient
environment makes check:generated-types environment-sensitive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:26:07 +05:30
ClintchizandClaude Opus 5 ac164789bd fix(dev): serve the HMR client as an external script
A document's CSP nonce is fixed at load, so an inline script delivered by
a later response can never carry a nonce that document accepts. The HMR
client is now served at /__wrnexus/hmr-client.js, which script-src 'self'
already covers and which needs no nonce at all.

This removes one of the two inline scripts CSP was blocking in
development. The i18n data script is still blocked and needs the same
treatment; it is shared with the CSR navigation and HMR parsers, so
moving it spans @wrnexus/i18n, csr, and dev-server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:25:40 +05:30
ClintchizandClaude Opus 5 88ec3a5cb6 chore(example): regenerate basic-app queries
Regenerated output for the committed query generator: positional
placeholders now render as $1 rather than ?.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:15 +05:30
ClintchizandClaude Opus 5 28085b5a43 chore(db,scripts): pending migration and packaging tweaks
Pre-existing working-tree changes to migration SQL parsing, query
generation, and the packaging scripts. Committed as-is rather than
authored here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:02 +05:30
ClintchizandClaude Opus 5 5d7bd81601 fix(dev-toolbar): report development-appropriate budgets
Excludes the toolbar's own bundle from the JavaScript budget, raises the
development thresholds, and skips WRNexus UI and theme stylesheets when
measuring CSS coverage, so unminified development modules and framework
styles stop reading as application problems.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:01 +05:30
ClintchizandClaude Opus 5 5e114d867f feat(gateway): forward application identity on WebSocket upgrades
Adds gatewayWebSocketBackendHeaders so proxied upgrades carry application
identity while Bun keeps ownership of WebSocket framing.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:54 +05:30
ClintchizandClaude Opus 5 10421465df fix(styles): stop scanning every component for utility sources
Component discovery is not utility-source discovery: scanning all
built-in and plugin component directories made Tailwind/Iconify generate
rules for components the app never renders. Packages that need scanning
opt in through styles.source.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:53 +05:30
ClintchizandClaude Opus 5 52cce2c628 style(docs): apply Prettier to the React islands spec and plan
Formatting only; no content change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:45 +05:30
ClintchizandClaude Opus 5 afa2a8c093 chore(deps): move packages to TypeScript 6.0.3
Raises the typescript devDependency across the workspace, bumps package
versions, re-adds ignoreDeprecations, and repoints the @wrnexus registry.

These were pre-existing working-tree changes, committed as-is rather than
authored here. The .npmrc change redirects @wrnexus publishes from
registry.npmjs.org to registry.workroot.in — confirm that is intended
before publishing from this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:34 +05:30
ClintchizandClaude Opus 5 4dd7681bf7 fix(dev): repair the HMR client and keep islands alive across updates
The HMR client script was dead in the browser. HMR_CLIENT_JS is a
TypeScript template literal, so the regex [ \t\r\n] inside it was expanded
into real control characters, producing a regex literal containing a raw
newline — a syntax error that took the whole script down with "Invalid
regular expression: missing /". It now uses \s, and a test asserts the
emitted client parses and holds no control characters inside regex
literals; that test fails if the bug is reintroduced.

HMR also corrupted CSP nonces. A document's nonce is fixed at load, but
morph copied attributes from freshly fetched HTML, overwriting the live
nonce with one the browser will not honour. syncAttrs now leaves nonce
alone, and nodes moved across are re-stamped with the live nonce.

Islands vanished on every HMR update: morph puts the server placeholder
back over the mounted island. The island runtime now remounts on
wrnexus:hmr-updated. Remounting swaps the container for a bare clone —
re-rendering the existing root is a no-op once HMR has wiped the DOM
externally, and unmounting throws asynchronously because the nodes React
wants to remove are already gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:15:21 +05:30
ClintchizandClaude Opus 5 843db2815f fix(islands): rebuild on .tsx edits and support islands inside components
Three bugs found by driving the dev server rather than reading code:

1. An island used inside a .wrn component still emitted a component mount
   — only the page and nested-page render paths were covered.

2. Editing an island .tsx never rebuilt in dev. The bundle cache was keyed
   on source path alone, and page modules are cached after the first
   request so no compile runs to notice the change. The cache key now
   includes mtime, and the file watcher rebuilds islands whose .tsx
   changed.

3. A .wrn cache hit skipped island building entirely, so after a restart
   with a warm cache no island bundle was ever produced. Island inputs are
   now persisted beside the other artifacts and rebuilt on a cache hit.

The islands manifest is deliberately excluded from the artifact
completeness check: only the async compile path writes it, so requiring it
made the sync path miss the cache on every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:26:58 +05:30
ClintchizandClaude Opus 5 17aa3b98eb feat(islands): wire islands end to end
The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:16:03 +05:30
ClintchizandClaude Opus 5 442a3106ed test(islands): guard zero-JS routes and single-React bundling
Two guards protect the core promise: a route with no islands emits no
assets at all, and a page with several islands keeps React in one shared
chunk.

buildIslands now writes a generated entry per island instead of passing
component sources directly. Two islands sharing a source deduped to a
single entrypoint, and output order is not guaranteed to match input
order, so island names could bind to the wrong bundle.

Island modules are excluded from the editor compiler bundle: it globs
packages/compiler/src, and island-bundle.ts calls Bun.build while
island-codegen.ts imports @wrnexus/core — neither belongs in a Node-only
VS Code artifact.

Integration assertions share one build. bun test interferes with
Bun.build's module reads after several build calls in one process, while
the same calls succeed repeatedly outside the runner; production is
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:50:26 +05:30
ClintchizandClaude Opus 5 3115277e9d chore(ui): refresh the stale UI visual contract baseline
Card, Carousel, Footer, Navbar, input, select, and textarea last changed
in d78707be, but the baseline was last regenerated several commits
earlier, so check:ui-visual already failed on main.

Unrelated to the React islands work; committed separately so the feature
changeset does not absorb it. Only source hashes changed — no UI source
was modified here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:44:27 +05:30
ClintchizandClaude Opus 5 02fdfa3aee feat(react): remount islands on hot module replacement
Disposes and re-creates island roots after a source change. Island state
resets by design; Fast Refresh needs a Babel/SWC transform plus a
runtime and is out of scope for v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:34:36 +05:30
ClintchizandClaude Opus 5 5f66c8129c docs(react-islands): drop the write-during-render guard
Implemented and removed. A render-phase flag cleared on a microtask is
still set when React runs effects, so islands writing from an effect —
the documented correct pattern — would throw. The flag also cannot be
set for an island's own re-renders, so real violations pass silently.

Detecting React's render phase reliably needs React internals, which is
not acceptable in a shipped framework. React already reports the real
hazard, and the getSnapshot caching requirement covers the loop case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:33:48 +05:30
ClintchizandClaude Opus 5 01a3b3b4e9 feat(compiler): classify island routes as static-interactive
A route mounting an island ships JavaScript, so reporting it as zero-JS
static would make the framework's performance accounting wrong.

Adds a separate needsIslandRuntime flag rather than reusing
needsClientRuntime: an island needs the island runtime, not WRNexus's
reactive runtime, and conflating them would ship the wrong bundle.

analyzeRuntimeRequirements takes island presence as an optional second
argument, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:30:19 +05:30
ClintchizandClaude Opus 5 a184f1a3be feat(islands): serve the island runtime in dev, prod, and static builds
Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/
prefix (mount runtime, island bundles, shared chunks) to all three
serving paths.

Dev reuses the browserArtifactPaths registry pattern from pipeline.ts.
Prod mirrors the clientModulesDir handler, including its filename
allowlist, so island names cannot escape the output directory.

The bootstrap is inert without a data-wrn-island marker, so island-free
pages still download nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:28:10 +05:30
ClintchizandClaude Opus 5 06df9d66ae feat(compiler): bundle islands with a shared React chunk
splitting:true keeps React in one shared chunk so a page with several
islands does not ship react-dom repeatedly.

Island .tsx is compiled against React's JSX runtime via a Bun onLoad
plugin. The repo's root tsconfig sets jsxImportSource to @wrnexus/core,
so islands would otherwise compile to the HTML-string renderer and never
mount. A @jsxImportSource pragma only affects the file carrying it, so
injecting one into the generated entry is not enough — the injection has
to happen per source file. App-authored islands stay plain .tsx.

The JSX test asserts built output rather than generated entry text,
because the entry-text assertion passed while the mechanism did not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:22:58 +05:30
ClintchizandClaude Opus 5 d269772a79 feat(react): add island mount strategies and navigation-safe unmount
Mounts markers with client:only/load/visible/idle, and disposes roots on
route change so React roots, detached DOM, and store subscriptions do
not leak across client-side navigation.

Bundle load failures and malformed props JSON degrade to a warning and
leave the server markup intact rather than taking down the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:20:05 +05:30
ClintchizandClaude Opus 5 df2ee036eb feat(react): add per-island error boundary
A crashed island renders its error in dev and nothing in prod, leaving
the surrounding server-rendered page intact.

Island .tsx sources carry an explicit @jsxImportSource react pragma: the
repo's root tsconfig points jsxImportSource at @wrnexus/core, so without
it island JSX compiles to WRNexus's string renderer instead of React
elements.

Tests render on the client via createRoot rather than a server renderer,
because React error boundaries do not engage during SSR — and islands
are client-only regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:16:42 +05:30
ClintchizandClaude Opus 5 b405025f37 feat(compiler): resolve .tsx imports and tag them as islands
.wrn keeps resolution priority so existing components are unaffected
when a .tsx file shares their name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:15:08 +05:30
ClintchizandClaude Opus 5 cd07f0f8e2 feat(compiler): add island marker codegen and props contract
Emits the data-wrn-island placeholder, parses client:* strategies, and
rejects non-serializable props at compile time via WRN-ISLAND-PROPS so
the serialization boundary fails where it is cheapest to fix.

Island names become URL path segments when the browser fetches the
island bundle, so they are validated with core's existing
isSafeIslandName rather than relying on escaping alone. This adds
@wrnexus/core to the compiler's dependencies; core has no dependencies
of its own, so no cycle is introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:13:56 +05:30
ClintchizandClaude Opus 5 ce67d0d1d2 feat(react): add useWrnStore bridge over useSyncExternalStore
Resolves WRNexus stores from inside islands, reading through the cached
snapshot so React sees a stable reference. Unknown store names throw
with the list of registered stores rather than failing opaquely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:12:33 +05:30
ClintchizandClaude Opus 5 468f63f378 feat(react): add referentially-stable snapshot and selector caches
useSyncExternalStore requires getSnapshot to return an identical
reference when unchanged; @wrnexus/store's readonlySnapshot returns a
fresh clone per call. The cache lives here rather than in the store
package so existing consumers are untouched.

createSelectorCache takes an optional equality function: the Object.is
default can never stabilize a selector that allocates, which is the
usual source of infinite re-renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:11:22 +05:30
ClintchizandClaude Opus 5 a6570d7f68 docs(react-islands): add implementation plan
Twelve TDD tasks covering the approved spec: snapshot cache, store
bridge, marker codegen, .tsx resolution, error boundary, island runtime,
bundling with a shared React chunk, asset serving, route classification,
integration guards, the write-during-render guard, and HMR remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:07:32 +05:30
ClintchizandClaude Opus 5 4ef2ef14ff docs(react-islands): add design spec for opt-in React islands
Adds the approved design for consuming npm React components as
client-only islands inside WRNexus, without changing the SSR-first
rendering model.

Key decisions:
- New isolated @wrnexus/react package; react/react-dom as optional peers
- Client-only by default, SSR deferred to v2
- Islands declared via .tsx imports in .wrn frontmatter
- Store access via useSyncExternalStore, with the snapshot cache in the
  adapter so @wrnexus/store stays untouched
- Bundling extends the existing Bun pipeline; React as a shared chunk
- Routes with no islands must still ship zero framework JavaScript

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:01:58 +05:30
Clintchiz d78707be9f fix(forms): surface validation and recover schema drift
Quality / quality (ubuntu-latest) (push) Failing after 10m23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 13:01:10 +05:30
Clintchiz 1d16ef1e82 chore(release): refresh ui consumers
Quality / quality (ubuntu-latest) (push) Failing after 9m50s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:33:40 +05:30
Clintchiz 12a1014db2 fix(ui): bind textarea values without markup
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:30:46 +05:30
Clintchiz 52b3e6d378 fix(csr): preserve translations across navigation
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:26:01 +05:30
Clintchiz f88dd47408 fix(runtime): stabilize navigation and custom errors
Quality / quality (ubuntu-latest) (push) Failing after 23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 10:44:06 +05:30
Clintchiz f0447fddb0 fix(db): share registry across bundled copies
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-13 19:10:51 +05:30
186 changed files with 41006 additions and 3112 deletions
+3
View File
@@ -25,3 +25,6 @@ tsconfig.focus.json
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
# which requires the scaffold to live inside the repo tree).
**/test/.tmp-*/
# Subagent-driven-development scratch (ledger, briefs, review packages)
.superpowers/
+1 -1
View File
@@ -1,3 +1,3 @@
@wrnexus:registry=https://registry.npmjs.org/
@wrnexus:registry=https://registry.workroot.in/repository/npm/
audit=true
fund=false
+1
View File
@@ -13,6 +13,7 @@ bun.lockb
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
**/*.gen.ts
**/*.generated.d.ts
**/*.generated.api-checks.ts
# Bundled .wrn compiler for the VS Code extension (generated)
editors/vscode/src/compiler.cjs
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## Unreleased
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
required boolean field given `false` still errors, as before (checkbox-required semantics
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
to start failing.
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
when no input is passed explicitly. Previously such endpoints silently validated
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
what was sent. **Behavior change for downstream apps:** a request that previously passed
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
priority over reading the request.
## 0.8.8
- Added the framework request context to `.wrn` language-server type environments.
+214 -741
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -166,6 +166,10 @@ Supported view features include:
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
- comments and scoped styles
`{#if}` and `{#each}` are rendered on the server for the initial response and
remain reactive after hydration. Browser state changes switch conditional
branches and rerender loop rows, including the `{:empty}` branch.
Output is escaped by default. Explicit raw HTML APIs must be treated as security
boundaries.
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -973,6 +973,10 @@
"EffectBlock",
"EventDecl",
"FormatWrnOptions",
"IslandBuildResult",
"IslandDiagnostic",
"IslandInput",
"IslandStrategy",
"LexError",
"Lexer",
"LoadBlock",
@@ -999,7 +1003,9 @@
"analyzeOptimizations",
"analyzeRuntimeImports",
"analyzeRuntimeRequirements",
"assertReactAvailable",
"assertValidAst",
"buildIslands",
"compilationKey",
"compile",
"compileNativeWrnFile",
@@ -1015,19 +1021,27 @@
"generate",
"generateBrowserModule",
"generateDeclarations",
"generateIslandEntry",
"generateNative",
"generateServerFunctionsModule",
"generateStoreBrowserModule",
"generateStoreModule",
"generateTargets",
"inferredRuntimeType",
"islandNamesFrom",
"islandPropValue",
"optimizeAst",
"parse",
"parseIslandStrategy",
"renderIslandMarker",
"resolveWrnImport",
"resolveWrnImports",
"routeNeedsIslands",
"rpcManifest",
"runtimeCapabilities",
"runtimeTypeOf"
"runtimeTypeOf",
"serializeIslandProps",
"stripBrowserTypes"
]
},
"@wrnexus/content": {
@@ -1661,6 +1675,7 @@
"@wrnexus/i18n": {
".": [
"ExtractedTranslationKey",
"I18N_DATA_ATTRIBUTE",
"I18N_JS_HREF",
"I18N_RUNTIME",
"I18nConfig",
@@ -1697,6 +1712,7 @@
"plural",
"pseudoLocalize",
"renderI18nData",
"renderI18nDataTag",
"resolveI18n",
"resolveLang",
"translateHtml",
@@ -2259,6 +2275,42 @@
"subjectQueue"
]
},
"@wrnexus/react": {
".": [
"BoundStore",
"IslandErrorBoundary",
"IslandErrorBoundaryProps",
"IslandStore",
"MountOptions",
"SnapshotCache",
"SnapshotSource",
"StoreResolver",
"createSelectorCache",
"createSnapshotCache",
"discardDetachedRoots",
"islandRootCount",
"mountIslands",
"remountIslands",
"setStoreResolver",
"unmountIslands",
"useWrnActions",
"useWrnStore"
],
"./browser": [
"MountOptions",
"discardDetachedRoots",
"islandRootCount",
"mountIslands",
"remountIslands",
"setStoreResolver",
"unmountIslands",
"useWrnActions",
"useWrnStore"
],
"./runtime": [
"getIslandRuntime"
]
},
"@wrnexus/reactive": {
".": [
"AnimationTimeline",
@@ -2802,7 +2854,10 @@
"LexError",
"Lexer",
"Token",
"TokenType"
"TokenType",
"isIdentPart",
"isIdentStart",
"skipLiteralOrComment"
],
"./types": [
"RuntimeType",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
# React Islands for WRNexus — Design
**Date:** 2026-08-18
**Status:** Approved for implementation
**Scope:** Add opt-in React islands to WRNexus without altering the existing SSR-first rendering model.
## Goal
Give WRNexus authors access to the npm React ecosystem — charts, editors, date pickers,
drag-and-drop, maps — without rebuilding those components natively and without adopting React
as the framework's rendering model.
This is explicitly **not** a migration path toward React, and not a replacement for `.wrn`
components. Islands are a consumption path for third-party components.
### Non-goals
- Server-rendering islands (deferred; see "Deferred to v2").
- React Fast Refresh.
- `bind:` syntax sugar for store binding.
- Replacing the `.wrn` authoring format.
## Guiding constraint
WRNexus's differentiator is that non-interactive routes ship **zero** framework JavaScript.
Every decision below is subordinate to preserving that. An app that uses no islands must be
byte-for-byte unchanged, and a route with no islands must ship no React.
## Decisions
| Question | Decision |
| ---------------- | ---------------------------------------------------------------------------------------- |
| Purpose | npm ecosystem access |
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
| Bundling | Extend the existing Bun pipeline |
| Packaging | New isolated package `@wrnexus/react` |
## Architecture
### Package boundary
All React-specific code lives in a new package, `@wrnexus/react`. `react` and `react-dom` are
declared as **optional peer dependencies**, so both the bundle cost and the dependency itself
land only on apps that import a `.tsx` island.
This boundary is deliberate: React concerns must never leak into `core`, `csr`, `store`, or
`compiler` beyond the narrow, explicitly enumerated hooks below. If islands do not earn their
keep, the feature is removed by deleting one package and reverting a small number of tagged
integration points.
### Island lifecycle
The compiler emits a placeholder element carrying `data-wrn-island` (name, strategy, serialized
props) alongside the existing `data-wrn-scope` marker.
The island runtime is lazy-loaded using the same marker-presence pattern as
`loadComponentControllers` in `packages/csr/src/index.ts` — fetched only if a `data-wrn-island`
marker exists in the document. A page with no islands downloads nothing, including React.
Mount strategies:
- `client:only` (default) — mount via `createRoot` once the bundle arrives.
- `client:load` — mount on document load.
- `client:visible` — mount via `IntersectionObserver`.
- `client:idle` — mount on `requestIdleCallback`.
**Unmount is mandatory.** WRNexus has client-side navigation (`packages/csr/src/nav-runtime.ts`).
Island roots are tracked per scope and explicitly `root.unmount()`-ed on route change. Omitting
this leaks React roots, detached DOM, and store subscriptions on every navigation.
### Asset serving
Two additions following the existing `/__wrnexus/*` convention:
- `/__wrnexus/islands.js` — the mount runtime.
- `/__wrnexus/island/<hash>.js` — per-island bundles, content-hashed.
Registered in the three existing locations:
- `packages/dev-server/src/assets.ts` (dev)
- `packages/dev-server/src/prod.ts` (prod)
- `packages/cli/src/build.ts` (static build)
## Store bridge
### The hook
`useWrnStore(name, selector?)`, built on `useSyncExternalStore`.
`StoreInstanceCore` (`packages/store/src/types.ts`) already provides both halves of React's
external-store contract — `subscribe(listener) => unsubscribe` and `snapshot()` — so the
adapter is thin. Islands resolve the browser container via `browserStoreContainer()` from
`packages/store/src/client.ts`, reading the store already hydrated from the server render. No
separate island hydration channel is introduced.
### Snapshot caching (load-bearing)
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
referentially identical value when nothing has changed; otherwise React throws
_"The result of getSnapshot should be cached to avoid an infinite loop"_ and spins.
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
callback fires, then recomputes.
Rationale: changing `readonlySnapshot` would alter semantics for all existing consumers and the
current test suite in order to serve one new caller. Keeping the cache in the adapter leaves the
store package untouched and confines this feature's blast radius to the new package.
### Selectors
`snapshot()` returns whole state, so without a selector any mutation re-renders every island
bound to that store. `useWrnStore("cart", s => s.itemCount)` caches the selected value and
compares with `Object.is`, re-rendering only on actual change.
### Writes
Writes go through `instance.actions.*`, never direct state assignment. The `mutableState` Proxy
would technically accept a raw write, but that bypasses action naming and the `StoreMutation`
record that subscribers and devtools depend on.
### The one author-facing rule
Write → action → store notifies → snapshot changes → island re-renders. This terminates cleanly
**provided writes never occur during render**. Writes belong in event handlers or effects.
This rule is documented rather than machine-enforced — see "Dropped: the write-during-render
guard" below. It is also the reason this shape was chosen over generated `bind:` sugar: the
cycle stays visible in the author's own code rather than being hidden in generated glue.
## Compiler and bundler changes
### Detection
`candidates()` in `packages/compiler/src/import-resolver.ts` currently resolves `.wrn`, `.ts`,
`.d.ts` and index variants. Add `.tsx` and `index.tsx`, and tag `ResolvedImport` with
`kind: "island"` when the resolved path ends in `.tsx`.
Because authoring uses an explicit frontmatter import, detection requires no heuristics and no
configuration.
### Server codegen
Where a `.wrn` component import generates a server render call, an island import instead emits
the placeholder marker with name, strategy, and props serialized as JSON through the existing
`escapeHtml`. Since islands are client-only in v1, the server never imports React.
### Props contract
Island props must be JSON-serializable. Passing a function, symbol, or class instance is a
compile-time diagnostic (`WRN-ISLAND-PROPS`) rather than a runtime failure. This makes the
serialization boundary explicit at the point where it is cheapest to correct.
### Bundling
Each island gets a generated entry (component + mount runtime), bundled via `Bun.build`, with
content-hashed output.
**React must be emitted as a shared chunk.** Five islands on one page must not ship five copies
of `react-dom`. This is a day-one splitting requirement, not a later optimization, because
getting it wrong fails silently and multiplies bundle size.
### Route classification
The compiler already classifies routes (static, static-interactive, request SSR, and so on), and
that classification determines whether a route ships JavaScript. A route containing an island is
no longer zero-JS static — it is static-interactive. Islands must feed into that existing
classifier so the framework's performance reporting stays accurate.
### HMR
On island source change: unmount the root and re-mount with the new bundle. Correct and simple;
the cost is that component state resets on edit.
React Fast Refresh requires a Babel/SWC transform plus a runtime and is out of scope. If authors
report that state-preserving edits matter, that is the concrete evidence that would justify
introducing Vite or esbuild for island bundling — and the bundler interface is the intended swap
point.
## Error handling
| Condition | Behavior |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
| Cleanup throws on unmount | Caught and logged; navigation must not break |
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
the rest of the page working.
## Dropped: the write-during-render guard
The design originally called for a dev-only guard that threw when an island
called a store action during render. It was implemented, then removed: the
mechanism is unreliable in both directions.
- **False positives.** React runs effects before a queued microtask drains, so a
render-phase flag cleared on a microtask is still set inside `useEffect`. An
island writing from an effect — the documented correct pattern — would throw.
- **False negatives.** The flag can only be set from the error boundary`s
render. When an island updates its own state, only the island re-renders, so
the flag is never set and a genuine write-during-render passes silently.
There is no reliable public API for detecting React`s render phase; doing it
properly requires React internals, which is not acceptable in a shipped
framework.
React already covers the real hazard: writing during render that notifies
subscribers produces "Cannot update a component while rendering a different
component", and the infinite-loop case is caught by the `getSnapshot` caching
requirement handled in the store bridge. The custom guard added false positives
without covering anything React misses.
The author-facing rule still stands and is still documented — it is simply not
machine-enforced.
## Testing
### Compiler unit tests
- `.tsx` resolution through `candidates()`
- Marker emission with correct name, strategy, and props
- JSON serialization and escaping of props
- `WRN-ISLAND-PROPS` diagnostic for non-serializable props
- `WRN-ISLAND-REACT-MISSING` diagnostic
- Route reclassification from static to static-interactive when an island is present
### Store bridge unit tests
- **`getSnapshot()` returns a referentially identical value across repeated calls with no
mutation, and a new one after a mutation.** This single test stands between the
implementation and an infinite render loop.
- Selector memoization and `Object.is` change detection
- `subscribe`/`unsubscribe` symmetry
### Island runtime tests (`happy-dom`, already a dev dependency)
- Mount per strategy: `only`, `load`, `visible`, `idle`
- Error boundary containment
- **Unmount on navigation** — roots disposed, store subscriptions released; subscription counts
stay flat across repeated simulated navigations
### Integration guards
Both protect the core promise:
1. A route with no islands ships **zero** framework JavaScript.
2. A page with multiple islands ships React exactly **once**.
All of the above run under the existing `bun test packages` and `test:examples`, so
`check:production` covers islands from day one.
## Deferred to v2
- **SSR opt-in** — `renderToString` + `hydrateRoot` for libraries that support it. The marker and
bundling design already accommodate this; only the server codegen path and a hydration
strategy are missing.
- **`bind:` sugar** — generated two-way binding in `.wrn` markup, layered over the v1 store
bridge as pure syntax. Add only if authors ask.
- **React Fast Refresh** — see HMR above.
@@ -0,0 +1,252 @@
# HTML editing support for `.wrn` files — Design
**Date:** 2026-08-18
**Status:** Approved for implementation
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
## Goal
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
tag-level folding.
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html``html`), which is
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
### Non-goals
- **HTML formatting.** See "Formatting is deliberately excluded" below.
- Editor support outside VS Code beyond what standard LSP gives for free.
- Changing `.wrn` syntax or the compiler.
## Decisions
| Question | Decision |
| ------------------- | ---------------------------------------------------------------------------- |
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
| Region detection | Tolerant scanner over a virtual document, not the AST |
| HTML knowledge | `vscode-html-languageservice` |
| Formatting | Excluded — `formatWrn` already owns markup formatting |
## Architecture
### Virtual HTML document
New module: `packages/language-server/src/html-regions.ts`, exporting
`virtualHtmlDocument(document)`.
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
newlines preserved. The virtual document therefore has the same size and the same line/column
geometry as the source, so a position in the source _is_ the position in the virtual document.
No mapping table and no translation layer.
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
compacts code and carries line mappings back to source. Compaction is necessary there because
the output must be valid TypeScript. HTML has no such requirement, so the simpler
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
does not arise.
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
newlines at identical offsets. If this breaks, every feature reports positions off by some
amount rather than failing loudly.
### Region detection
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
while the document is being typed, which is exactly when it does not parse. The scanner finds
`view` followed by `{` and tracks brace depth to the matching close.
Two hazards it must handle, both of which defeat a naive implementation:
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
delimiter anywhere will consider the rest of the file one open string and lose every later
region. Quotes are tracked only inside attribute values, never in text nodes.
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
depth must be counted rather than scanning for the next `}`.
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
costs a single scan.
## Completion
### The server becomes the single authority inside view blocks
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
virtual document is non-blank there, which costs one character lookup.
**Inside a view block**, one list is assembled from two sources:
| Source | `sortText` prefix | Content |
| ------- | ----------------- | ------------------------------------------------------------------------ |
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
| HTML | `1` | Tags, attributes, attribute values |
`sortText` drives ordering independently of the label, so components rank above HTML tags
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
plus workspace items.
The server already indexes components, props, outputs, and slots
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
of the merge are already available to it.
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
resolves to the component.
### Trigger characters
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
`" "`, `"="`, `"\""`, and `"/"`.
### This fixes an existing bug
The extension's `completion.js` registers its own provider with `<` among its trigger
characters, and the language server answers `textDocument/completion` as well. VS Code
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
involved at all.
As part of this work the extension's provider returns nothing when the position is inside a view
block, and keeps its current behaviour elsewhere. One owner per context.
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
context, so future component-intelligence work belongs in the server rather than in
`completion.js`.
## Hover
`textDocument/hover` answers from the HTML service over the virtual document when the position
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
region, existing hover behaviour is unchanged.
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
any HTML entry of the same name, matching the completion precedence rule above.
## Tag handling
### Linked editing is standard LSP
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
it lives in the shared server like everything else.
### Auto-close on type is the one client-side piece
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
client-side, and this follows the same shape:
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
snippet or `null`.
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
`wrnexus.*` naming.
### Emmet
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
edge is real.
## Folding
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
the virtual document, filtered to view regions.
Today folding comes only from `language-configuration.json` markers, which work at block level
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
to work unchanged and tag folding appears inside markup.
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
outside a view region would let a fold swallow a brace boundary.
## Formatting is deliberately excluded
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
`printWidth`. It is a markup formatter that understands WRNexus syntax.
Adding HTML formatting would do two harmful things:
- **Two formatters would fight.** Output would depend on which ran last.
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
## Dependencies
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
`editors/vscode`.
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
and passes other `require`s through to Node, so the package must be resolvable at runtime from
the extension. `editors/vscode` currently ships exactly one runtime dependency
(`vscode-languageclient`); this adds the second.
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
## Testing
### Region scanner (`packages/language-server/test/`)
- **The invariant**, property-style across fixtures: virtual text length equals source length and
newlines sit at identical offsets.
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
found.
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
completion, not an edge case.
- **Multiple view blocks**, and files with none.
### Completion
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
- Outside a view block: response identical to current behaviour — the guard proving non-markup
contexts are undisturbed.
- Collision: a component named `Table` yields one entry, the component.
### Tag handling
- `<div>``</div>`; `<br>` → nothing; `<Card /``/>`; outside a view region → `null`.
- Linked editing returns ranges covering both the opening and closing tag names.
### Hover
- Inside a view region, a known tag returns HTML documentation.
- A component name returns the component detail, not an HTML entry of the same name.
### Folding
- Every returned range lies inside a view region.
- Block-level marker folding still works.
### Toolchain guards
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
- Manifest assertion that `emmet.includeLanguages` maps `wrn``html`, alongside the existing
marketplace checks in `editors/vscode/test`.
## Deferred
- HTML formatting — see above; improve `formatWrn` instead.
- Moving the remaining `completion.js` component intelligence into the server. This design only
requires it to stand down inside view blocks; relocating the rest is follow-up work.
@@ -0,0 +1,276 @@
# Typed, callable `api` blocks for `.wrn` files — Design
**Date:** 2026-08-19
**Status:** Approved for implementation
**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and
handles failure — callable on demand from client code.
## Goal
Calling this application's own API routes from a `.wrn` page should be declarative and
type-checked. Today it is neither: the `api` block takes no parameters at all, so anything
carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly,
JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site.
### What the current block cannot do
These are implementation facts, not gaps in documentation:
- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path
containing `?` or `#`.
- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}`
ends the path at the brace and the remainder is parsed as the block body.
- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no
parameter a payload could occupy, whatever method is named.
- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later
pass, so a binding cannot be re-run.
### Non-goals
- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving
the existing `isSafeApiPath` guarantee.
- Replacing `server function`. That remains the way to run arbitrary server logic over RPC.
- Author-settable headers. See "Why `headers` is excluded".
- Parameterised server-render fetching. See "The SSR boundary".
## Decisions
| Question | Decision |
| ---------------- | ------------------------------------------------------------------------ |
| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time |
| Targets | This app's `/api/*` routes only |
| Execution | Decided by the enclosing mode, not a modifier |
| Request values | Declared fields, supplied at the call site |
| Type source | Route contract when available, declared types otherwise (with a warning) |
| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.api-checks.ts` |
| Failure | `error {}` converts a failure to a value; without it, the call rejects |
## Syntax
```wrn
client {
api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
designation?: string
}
}
response {
return data.users
}
error {
return []
}
}
}
```
Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace
joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way
as `server.searchUsers()`.
`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at
call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without
relaxing it.
### Backward compatibility
A bare body keeps meaning "this is the response block", unchanged:
```wrn
ssr {
api ssrUsers GET /api/users/ssr {
return users.map((user) => user.name).join(", ")
}
}
```
The rule is **bare body = legacy untyped block; sections = typed block.**
The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic.
The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves.
**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is
impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so
`tsc` can check `data.users` against the route's contract.
### Why `headers` is excluded
Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow
from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an
author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a
concrete case.
## Type safety
### The constraint that shapes this
`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and
`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling
the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing.
What _is_ type-checked is application source under `app/`. Enforcement goes there.
**Corrected 2026-08-19, during implementation.** This section originally placed the assertions in
`app/types/wrnexus.generated.d.ts`. That is inert: the root `tsconfig.json` sets
`skipLibCheck: true`, which exempts the _contents_ of every `.d.ts`, so an assertion written there
can never raise a `tsc` error. Proven by forcing `skipLibCheck: false`, under which the same
assertion fires as `TS2344`. The reasoning was right and the file was wrong. Per-block assertions
are emitted into a real `.ts` file instead — `app/types/wrnexus.generated.api-checks.ts` — which
`skipLibCheck` does not exempt and which `include: ["app"]` compiles. The helper types stay in the
`.d.ts`, where being declarations is correct.
### Three pieces
**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`,
`ApiContract`):
```ts
type AssertAssignable<Actual, Expected> = Actual extends Expected ? true : never;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
```
**2. Per-block assertions**, generated into `app/types/wrnexus.generated.api-checks.ts`. `wrnexus generate types` already parses
`.wrn` sources to build the route list, so it can read each block's declared fields and emit:
```ts
type __wrn_check_searchUsers = AssertAssignable<
{ name?: string; age?: number },
ApiInput<"/api/users", "POST">
>;
```
This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so
`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type
comparison inside the WRNexus compiler, and no need to type-check build output. The language server
already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline.
**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch,
JSON handling, and failure branch live in one tested place instead of being re-emitted per block.
### Routes without a contract
A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
the route. Untyped endpoints stay visible rather than silently passing.
### GET parameters travel as strings
A `GET` block's `parameters` become a query string (see Request assembly below), and every
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
only because the endpoint's own schema coerces it back: `checkField` in
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
route also has no contract for the generator to check against, so it already falls under "Routes
without a contract" above and is flagged there.
### Staleness
Checking is only as current as the generated file, so this stays wired into the existing
`check:generated-types` gate, which already verifies those artifacts match their sources.
## Compilation and runtime
### Client mode
Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser
module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to
client scope exactly as `server` is today.
Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and
codegen drops them before emit. A client function body that carried TypeScript into a `.mjs`
artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same
discipline applies here.
`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time
binding needs no rework.
### Request assembly
`callApi` builds the request:
- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which
removes the `if (filter.trim())` ladder authors write by hand.
- **Everything else** — a JSON body with `content-type: application/json`.
- Always `credentials: "same-origin"` and `accept: application/json`.
- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the
`wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221`
for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a
correctness gain rather than only less typing.
### Failure
**`error {}` converts a failure into a value; without it, the call rejects.**
- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the
call's result. With no `response` block, `data` is returned unchanged.
- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and
`data` in scope. `return []` yields an empty list and no exception.
- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working.
A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class
this design is most concerned with, so the absence of an `error` block means throw, never swallow.
### The SSR boundary
`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at
render time to supply arguments, and inferring an implicit source — page state, query parameters —
would be a guess. Parameterised requests are a client-mode feature; parameterised server-side
fetching stays with `server function`.
## Testing
### Parser (`packages/syntax/test/`)
- A sectioned block parses into `request`/`response`/`error` parts.
- A bare body still parses as the response block (the backward-compatibility guarantee).
- `request` inside an `ssr {}` block is a parse error naming the restriction.
- A malformed section reports the offending offset rather than failing later in codegen.
### Type generation (`packages/cli/test/` or the types generator's suite)
- A block targeting a `defineEndpoint` route emits an assertion referencing that contract.
- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc`
over a fixture, not by string-matching the generated file.
- A block targeting a contract-less route emits the warning and falls back to declared types.
- `check:generated-types` still passes with blocks present.
### Codegen (`packages/compiler/test/`)
- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives
into the browser module (the guard for the `55fed217` defect class).
- Declared types do not appear in the emitted module.
- An `ssr` block's output is unchanged from today for a bare body.
### Runtime (`packages/csr/test/`)
- GET omits `undefined` parameters and includes the rest.
- Non-GET attaches `x-csrf-token` from cookie and from meta.
- 2xx runs `response`; its return value is the result.
- Non-2xx runs `error`; its return value is the result.
- With no `error` block, a non-2xx rejects rather than resolving to `undefined`.
### End to end (`examples/basic-app`)
A page calling a typed block against a real route, driven in a browser: the request carries the
declared fields, the response block's value reaches page state, and a deliberately failing call
takes the `error` path. Tests that pass while the feature does not work have been a recurring
failure in this repository, so browser verification is part of the definition of done.
## Deferred
- External and third-party API targets, with the allowlist and credential handling they require.
- Author-settable request headers.
- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is.
- Parameterised server-render fetching.
- Response caching and request de-duplication.
+7 -7
View File
@@ -13,8 +13,8 @@
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
"packages/ui/components/Card.wrn": "255912df0668ebcf45b6cc3ffe99a4a3e0bf87376a5d199e3adcdf3610ea79e3",
"packages/ui/components/Carousel.wrn": "7ab122bc0ce772346c665b43d62e73c9865a13b97c02ccf5b03d1e3eaea757d6",
"packages/ui/components/Card.wrn": "3cc2ff238bda279c169026a462236144ad11ec0046deef00ab00e001a07e8a4c",
"packages/ui/components/Carousel.wrn": "e7d921f802aa19210f3f415b59b70750a2c8d78e30684ad334809741c70a6bf9",
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
@@ -39,7 +39,7 @@
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
"packages/ui/components/Footer.wrn": "c498820a160c1286331a423a4498054e7852d2f1a9eb6e81eb5b008b1693efc4",
"packages/ui/components/Footer.wrn": "f7c5a77064a09e244f689d42475c4c20143f4c8859cac451c10a50c14652db33",
"packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
@@ -60,7 +60,7 @@
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
"packages/ui/components/Navbar.wrn": "ed3bb1974b93d52c480af468bdb1c0b253cb9196f3a00602614d3ce1e271a669",
"packages/ui/components/Navbar.wrn": "01903230210cd2e5e6d9325a0708f85af9623d4118e3a53f1adb770fd545425d",
"packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
@@ -95,14 +95,14 @@
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
"packages/ui/components/input.wrn": "a576e5ee2c6d0da963ebb14e8809ddf3ec7333b1eae095d5a4b7fce3ceb1fb55",
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
"packages/ui/components/radio.wrn": "09425a78358de5bbd2f47482f313e80065135335b969ce5ccdd5c7cc3ea5232c",
"packages/ui/components/select.wrn": "b107b259d228201e9071701370ad1c912ac9a8131f9e0a55834bcc84ef0dd417",
"packages/ui/components/select.wrn": "1d8d47a74d5ab9ed58d0ba11f46910897233b96652d17f5962f5dda60bd4cf8a",
"packages/ui/components/skeleton.wrn": "4fc5e0846eeefd7830c038e1789be995c4f9d833aa913ff079eb4863baa65648",
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
"packages/ui/components/switch.wrn": "874504e4828e9db6a570d78984c4d3a76d0ceb39d70baa877649cb3798c82c85",
"packages/ui/components/textarea.wrn": "76d432179f2b7790ab9cdd644752928c259e1f496ad89c4db79981b74bbd226f",
"packages/ui/components/textarea.wrn": "9870d37664471a103d44435a3977bf40b087a743acd76ee4d77df9b56cccbb2b",
"packages/ui/components/tooltip.wrn": "f3dfdfa5cd9661fe5e95ef3580eef4c7437c069726421370c2d7328fbe7d840d",
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
+11 -2
View File
@@ -1,5 +1,14 @@
# Changelog
## 0.8.8
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
component completion priority and suppressing HTML suggestions outside markup regions.
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
stale asynchronous edits, and duplicate client-side suggestions.
## 0.8.3
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
@@ -16,8 +25,8 @@
- Kept component prop/event intelligence active while the shared language server is enabled.
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
diagnostics in packaged extension environments.
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
consistently in the repository and extension development environment.
+12 -3
View File
@@ -134,6 +134,11 @@
"maximum": 240,
"scope": "resource",
"description": "Preferred WRNexus formatter line width before long tags are expanded."
},
"wrnexus.html.autoClosingTags": {
"type": "boolean",
"default": true,
"description": "Automatically close HTML tags inside .wrn view blocks."
}
}
},
@@ -141,6 +146,9 @@
"files.associations": {
"*.wrn": "wrn"
},
"emmet.includeLanguages": {
"wrn": "html"
},
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": false,
@@ -276,13 +284,14 @@
"check": "bun run build && bun run test && bun run validate",
"vscode:prepublish": "bun run check",
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
"publish": "vsce publish --no-dependencies",
"publish:azure": "vsce publish --no-dependencies --azure-credential"
},
"devDependencies": {
"@vscode/vsce": "^3.9.2"
},
"dependencies": {
"vscode-languageclient": "^10.1.0"
"vscode-languageclient": "^10.1.0",
"vscode-html-languageservice": "^5.6.2"
}
}
+83
View File
@@ -0,0 +1,83 @@
"use strict";
const vscode = require("vscode");
/**
* Auto-close tags as they are typed.
*
* LSP has no request for this, so the client watches document changes and asks
* the server whether the tag should close. The server owns the decision because
* void elements and already-closed tags must not be closed.
*/
function registerAutoCloseTags(context, client) {
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
if (event.document.languageId !== "wrn") return;
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
const changes = event.contentChanges;
if (!changes.length) return;
const typed = changes[0].text;
if (typed !== ">" && typed !== "/") return;
// Every cursor must have typed the same trigger. A replaced selection
// (overtype, or select-and-type) is declined rather than guessed at.
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document !== event.document) return;
/*
* Positions come from the editor's selections, not from the changes.
*
* A change's `range` is in coordinates from before the whole event, so with
* several cursors on one line every range after the first is short by the
* insertions preceding it. The selections have already been adjusted for
* the edit, so they are where the carets actually are.
*/
const positions = editor.selections.map((selection) => selection.active);
if (positions.length !== changes.length) return;
if (!editor.selections.every((selection) => selection.isEmpty)) return;
const documentVersion = event.document.version;
const snippets = await Promise.all(
positions.map((position) =>
client.sendRequest("wrn/tagComplete", {
textDocument: { uri: event.document.uri.toString() },
position: { line: position.line, character: position.character },
}),
),
);
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
/*
* One insertSnippet call carries one snippet, and it is the only form that
* keeps every caret: inserting sequentially would collapse the selection to
* the first snippet and invalidate the remaining positions. Cursors that
* want different closing tags are therefore declined rather than
* half-applied -- multi-cursor editing of matching lines, which is what
* this is for, produces one snippet for all of them.
*/
if (!snippets.every((snippet) => snippet === snippets[0])) return;
// The user may have kept typing during the round-trip; re-validate everything the
// insertion depends on before touching the document, since a stale offset would
// silently corrupt it.
if (vscode.window.activeTextEditor !== editor) return;
if (editor.document !== event.document) return;
if (editor.document.version !== documentVersion) return;
if (editor.selections.length !== positions.length) return;
if (
!editor.selections.every(
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
)
) {
return;
}
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
});
context.subscriptions.push(listener);
}
module.exports = { registerAutoCloseTags };
File diff suppressed because it is too large Load Diff
+38
View File
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
return depth > 0;
}
/**
* Whether an offset sits inside a `view { }` block.
*
* The language server owns completion there and returns a merged list, so this
* provider stands down to avoid VS Code concatenating two independent lists.
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
* string that never closes.
*/
function isInsideViewBlock(text, offset) {
const pattern = /\bview\s*\{/g;
let match;
while ((match = pattern.exec(text))) {
const start = match.index + match[0].length;
let depth = 1;
let inTag = false;
let quote = null;
let index = start;
for (; index < text.length && depth > 0; index += 1) {
const char = text[index];
if (quote) {
if (char === quote) quote = null;
continue;
}
if (inTag && (char === '"' || char === "'")) quote = char;
else if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (char === "{") depth += 1;
else if (char === "}") depth -= 1;
}
if (offset >= start && offset <= index) return true;
pattern.lastIndex = index;
}
return false;
}
function isAfterWatchKeyword(document, position) {
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
}
function provideCompletionItems(document, position) {
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
@@ -707,6 +744,7 @@ module.exports = {
extractProps,
extractRouteParams,
extractStates,
isInsideViewBlock,
provideCompletionItems,
registerCompletionProvider,
};
+56 -2
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 28a3937b948e6affb33150753d537162c6702786551dd90ab0968ef9166f21ac
// WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
}
});
// editors/vscode/src/auto-close-tags.js
var require_auto_close_tags = __commonJS((exports2, module2) => {
var vscode = require("vscode");
function registerAutoCloseTags(context, client) {
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
if (event.document.languageId !== "wrn")
return;
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
return;
const changes = event.contentChanges;
if (!changes.length)
return;
const typed = changes[0].text;
if (typed !== ">" && typed !== "/")
return;
if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
return;
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document !== event.document)
return;
const positions = editor.selections.map((selection) => selection.active);
if (positions.length !== changes.length)
return;
if (!editor.selections.every((selection) => selection.isEmpty))
return;
const documentVersion = event.document.version;
const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
textDocument: { uri: event.document.uri.toString() },
position: { line: position.line, character: position.character }
})));
if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
return;
if (!snippets.every((snippet) => snippet === snippets[0]))
return;
if (vscode.window.activeTextEditor !== editor)
return;
if (editor.document !== event.document)
return;
if (editor.document.version !== documentVersion)
return;
if (editor.selections.length !== positions.length)
return;
if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
return;
}
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
});
context.subscriptions.push(listener);
}
module2.exports = { registerAutoCloseTags };
});
// editors/vscode/src/extension.js
var path = require("node:path");
var vscode = require("vscode");
var { LanguageClient, TransportKind } = require_main5();
var { registerAutoCloseTags } = require_auto_close_tags();
var WRN_LANGUAGE_ID = "wrn";
var client;
async function recoverWrnLanguage(document) {
@@ -22730,6 +22783,7 @@ async function activate(context) {
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
await client.start();
registerAutoCloseTags(context, client);
}
async function deactivate() {
const running = client;
@@ -22737,4 +22791,4 @@ async function deactivate() {
if (running)
await running.stop();
}
module.exports = { activate, deactivate, recoverWrnLanguage };
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
+3 -1
View File
@@ -4,6 +4,7 @@
const path = require("node:path");
const vscode = require("vscode");
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
const { registerAutoCloseTags } = require("./auto-close-tags.js");
const WRN_LANGUAGE_ID = "wrn";
/** @type {LanguageClient | undefined} */
@@ -43,6 +44,7 @@ async function activate(context) {
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
);
await client.start();
registerAutoCloseTags(context, client);
}
async function deactivate() {
@@ -51,4 +53,4 @@ async function deactivate() {
if (running) await running.stop();
}
module.exports = { activate, deactivate, recoverWrnLanguage };
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
"use strict";
const assert = require("node:assert");
const { test } = require("node:test");
const { installVsCodeHost } = require("./vscode-host.js");
class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
translate(lineDelta, characterDelta) {
return new Position(this.line + lineDelta, this.character + characterDelta);
}
isEqual(other) {
return this.line === other.line && this.character === other.character;
}
}
class Selection {
constructor(active) {
this.active = active;
this.anchor = active;
this.isEmpty = true;
}
}
class SnippetString {
constructor(value) {
this.value = value;
}
}
let changeListener = null;
const host = {
Position,
Selection,
SnippetString,
workspace: {
onDidChangeTextDocument(listener) {
changeListener = listener;
return { dispose() {} };
},
getConfiguration() {
return { get: (_key, fallback) => fallback };
},
},
window: { activeTextEditor: null },
};
const restoreHost = installVsCodeHost(host);
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
restoreHost();
/**
* Drive the handler the way VS Code does: the document has already been
* updated and the carets moved by the time the change event fires.
*/
function scenario({ carets, snippetFor, typed = ">" }) {
const inserted = [];
const asked = [];
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
const editor = {
document,
selections: carets.map((caret) => new Selection(caret)),
insertSnippet(snippet, positions) {
inserted.push({ value: snippet.value, positions });
return Promise.resolve(true);
},
};
editor.selection = editor.selections[0];
host.window.activeTextEditor = editor;
const client = {
sendRequest(_method, params) {
asked.push(params.position);
return Promise.resolve(snippetFor(params.position));
},
};
registerAutoCloseTags({ subscriptions: [] }, client);
return {
inserted,
asked,
fire: () =>
changeListener({
document,
// Pre-edit coordinates, deliberately not usable as caret positions.
contentChanges: carets.map(() => ({
text: typed,
rangeLength: 0,
range: { start: new Position(0, 0) },
})),
}),
};
}
test("closes the tag at a single caret", async () => {
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
await run.fire();
assert.equal(run.inserted.length, 1);
assert.equal(run.inserted[0].value, "$0</div>");
assert.deepEqual(
run.inserted[0].positions.map((p) => [p.line, p.character]),
[[1, 8]],
);
});
test("closes the tag at every caret in one insertion", async () => {
// One insertSnippet call is what keeps all the carets alive: inserting
// sequentially would collapse the selection to the first snippet.
const run = scenario({
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
snippetFor: () => "$0</div>",
});
await run.fire();
assert.equal(run.asked.length, 3);
assert.equal(run.inserted.length, 1);
assert.deepEqual(
run.inserted[0].positions.map((p) => [p.line, p.character]),
[
[1, 8],
[2, 8],
[3, 8],
],
);
});
test("asks about each caret's own position rather than the change ranges", async () => {
// Every contentChange above reports (0, 0). Using those would query and
// insert at the wrong offsets once more than one caret is on a line.
const run = scenario({
carets: [new Position(4, 12), new Position(9, 3)],
snippetFor: () => "$0</p>",
});
await run.fire();
assert.deepEqual(
run.asked.map((p) => [p.line, p.character]),
[
[4, 12],
[9, 3],
],
);
});
test("declines when the carets want different closing tags", async () => {
const run = scenario({
carets: [new Position(1, 8), new Position(2, 8)],
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
});
await run.fire();
assert.equal(run.inserted.length, 0);
});
test("declines when any caret has no tag to close", async () => {
const run = scenario({
carets: [new Position(1, 8), new Position(2, 8)],
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
});
await run.fire();
assert.equal(run.inserted.length, 0);
});
test("declines a replaced selection", async () => {
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
await changeListener({
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
});
assert.equal(run.inserted.length, 0);
});
@@ -0,0 +1,103 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert");
const { installVsCodeHost } = require("./vscode-host.js");
const restoreHost = installVsCodeHost({
Position: class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
},
Range: class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
},
CompletionItem: class CompletionItem {
constructor(label, kind) {
this.label = label;
this.kind = kind;
}
},
CompletionItemKind: {
Event: 23,
Property: 10,
Function: 12,
Keyword: 14,
Variable: 13,
},
SnippetString: class SnippetString {
constructor(text) {
this.value = text;
}
},
MarkdownString: class MarkdownString {
constructor(text) {
this.value = text;
}
},
});
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
restoreHost();
const PAGE = `page Home {
view {
<div>hello</div>
}
functions {
function go() {}
}
}
`;
test("a markup offset is inside a view block", () => {
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
});
test("a functions-block offset is not inside a view block", () => {
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
});
test("provideCompletionItems returns empty array when inside view block", () => {
const document = {
getText() {
return PAGE;
},
offsetAt() {
return PAGE.indexOf("<div");
},
lineAt() {
return { text: "<div>hello</div>" };
},
fileName: "test.wrn",
};
const position = { line: 2, character: 4 };
const result = provideCompletionItems(document, position);
assert.equal(Array.isArray(result), true);
assert.equal(result.length, 0);
});
test("provideCompletionItems returns non-empty array when inside functions block", () => {
const document = {
getText() {
return PAGE;
},
offsetAt() {
return PAGE.indexOf("function go");
},
lineAt() {
return { text: " function go() {}" };
},
fileName: "test.wrn",
};
const position = { line: 5, character: 4 };
const result = provideCompletionItems(document, position);
assert.equal(Array.isArray(result), true);
assert(result.length > 0, "should return non-empty completions outside view block");
});
+3 -7
View File
@@ -2,17 +2,13 @@
const assert = require("node:assert");
const { test } = require("node:test");
const Module = require("node:module");
const { installVsCodeHost } = require("./vscode-host.js");
// These extraction helpers are pure, but their module also registers VS Code
// providers at runtime. Supply a minimal host shim for unit tests.
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === "vscode") return {};
return originalLoad.call(this, request, parent, isMain);
};
const restoreHost = installVsCodeHost({});
const { extractRouteParams, extractStates } = require("../src/completion");
Module._load = originalLoad;
restoreHost();
test("extracts dynamic route params from filename", () => {
const document = {
+18 -24
View File
@@ -2,30 +2,24 @@
const assert = require("node:assert");
const { test } = require("node:test");
const Module = require("node:module");
const { installVsCodeHost } = require("./vscode-host.js");
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === "vscode") {
return {
Diagnostic: class Diagnostic {
constructor(range, message, severity) {
this.range = range;
this.message = message;
this.severity = severity;
}
},
DiagnosticSeverity: { Error: 0, Warning: 1 },
Range: class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const restoreHost = installVsCodeHost({
Diagnostic: class Diagnostic {
constructor(range, message, severity) {
this.range = range;
this.message = message;
this.severity = severity;
}
},
DiagnosticSeverity: { Error: 0, Warning: 1 },
Range: class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
},
});
const {
findTopLevelDeclaration,
maskLeadingTrivia,
@@ -34,7 +28,7 @@ const {
validateLayoutUsage,
validateRootMembers,
} = require("../src/diagnostics");
Module._load = originalLoad;
restoreHost();
function mockDocument() {
return {
+15
View File
@@ -113,6 +113,21 @@ try {
readFileSync(join(root, rel), "utf8");
ok(`Marketplace document exists: ${rel}`);
}
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
emmetLanguages?.wrn === "html"
? ok("Emmet is mapped for wrn documents")
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
const autoClose =
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
autoClose?.type === "boolean" && autoClose?.default === true
? ok("auto-closing tags setting is contributed")
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
manifest.dependencies?.["vscode-html-languageservice"]
? ok("HTML language service ships as a runtime dependency")
: bad("HTML language service ships as a runtime dependency");
} catch (e) {
bad("Marketplace metadata", e.message);
}
+39
View File
@@ -0,0 +1,39 @@
"use strict";
/**
* Supply a stub `vscode` host so extension sources can be unit tested.
*
* These files run under `node --test` (see the package's test script), where
* patching `Module._load` is enough. A bare `bun test` from the repository
* root also picks them up by filename, and Bun resolves `require` through its
* own resolver without consulting `Module._load` -- so under Bun the same
* files failed with "Cannot find package 'vscode'". Registering a virtual
* module covers that case, leaving one shim that works under both runners.
*
* Returns a function restoring the original loader.
*/
function installVsCodeHost(stub) {
const Module = require("node:module");
if (typeof Bun !== "undefined") {
require("bun").plugin({
name: "vscode-host-stub",
setup(build) {
build.module("vscode", () => ({ exports: stub, loader: "object" }));
},
});
return () => {};
}
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === "vscode") return stub;
return originalLoad.call(this, request, parent, isMain);
};
return () => {
Module._load = originalLoad;
};
}
module.exports = { installVsCodeHost };
+5 -5
View File
@@ -19,10 +19,10 @@
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineEndpoint } from "@wrnexus/core";
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
interface DirectoryUser {
name: string;
designation: string;
}
const ALL: DirectoryUser[] = [
{ name: "Ajay", designation: "UI" },
{ name: "Asha", designation: "Backend" },
{ name: "Chen", designation: "UI" },
];
/**
* Schema-typed handler: input resolves to `{ name?: string }` from
* SearchDirectorySchema, so the block's request shape is checked for real
* (not against `unknown`, which the untyped-handler shape resolved to).
*/
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
input: SearchDirectorySchema,
description: "Case-insensitive substring search over the demo directory by name.",
handler(input) {
const needle = String(input.name ?? "").toLowerCase();
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
},
});
+1 -1
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
import type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts";
@@ -0,0 +1,10 @@
import { useState } from "react";
export default function Counter({ start = 0 }: { start?: number }) {
const [count, setCount] = useState(start);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
{`clicked ${count}`}
</button>
);
}
@@ -0,0 +1,38 @@
page ApiBlockDemo {
state nameFilter = "a"
state found = ""
state failed = ""
client {
api searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
error {
return []
}
}
}
functions {
client async function search(): Promise<void> {
const users = await api.searchDirectory({ name: nameFilter })
found = users.map((user) => user.name).join(", ")
}
}
view {
<main>
<button @click="search()">Search</button>
<p class="found" data-text="found">{found}</p>
<p class="failed" data-text="failed">{failed}</p>
</main>
}
}
@@ -0,0 +1,21 @@
import Counter from "../islands/Counter"
// React island demo. Route: /island-demo
//
// Counter.tsx is a plain React component: the compiler emits an island
// placeholder here instead of a server-rendered component mount, and the
// browser mounts it with createRoot.
page IslandDemo {
seo {
title = "Island demo"
description = "Mounts a React island inside a server-rendered WRNexus page."
canonical = "/island-demo"
}
view {
<main>
<h1>React island</h1>
<Counter start={3} client:visible />
</main>
}
}
+6
View File
@@ -4,11 +4,13 @@
export interface Routes {
"/": Record<string, never>;
"/about": Record<string, never>;
"/api-block-demo": Record<string, never>;
"/async-data": Record<string, never>;
"/chat": Record<string, never>;
"/client-only": Record<string, never>;
"/dashboard": Record<string, never>;
"/hello": Record<string, never>;
"/island-demo": Record<string, never>;
"/language-tools": Record<string, never>;
"/layout": Record<string, never>;
"/login": Record<string, never>;
@@ -26,11 +28,13 @@ export interface Routes {
export interface RouteNames {
"index": "/";
"about": "/about";
"api.block.demo": "/api-block-demo";
"async.data": "/async-data";
"chat": "/chat";
"client.only": "/client-only";
"dashboard": "/dashboard";
"hello": "/hello";
"island.demo": "/island-demo";
"language.tools": "/language-tools";
"layout": "/layout";
"login": "/login";
@@ -117,11 +121,13 @@ export function route<N extends RouteName>(
const paths: Record<RouteName, RoutePath> = {
"index": "/",
"about": "/about",
"api.block.demo": "/api-block-demo",
"async.data": "/async-data",
"chat": "/chat",
"client.only": "/client-only",
"dashboard": "/dashboard",
"hello": "/hello",
"island.demo": "/island-demo",
"language.tools": "/language-tools",
"layout": "/layout",
"login": "/login",
@@ -0,0 +1,5 @@
import { v } from "@wrnexus/validation";
export const SearchDirectorySchema = v.object({
name: v.string().trim().optional(),
});
@@ -0,0 +1,7 @@
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
//
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
// checked; this file is compiled and checked normally by the project's own tsc.
export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export {};
+13 -2
View File
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
}
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
type AssertAssignable<Actual, Expected> = unknown extends Expected
? true
: [Actual] extends [Expected]
? [Exclude<keyof Actual, keyof Expected>] extends [never]
? true
: false
: false;
type __wrn_expect_true<T extends true> = T;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
}
+6 -6
View File
@@ -23,12 +23,12 @@
"devDependencies": {
"@wrnexus/test": "workspace:*",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"eslint": "^10.8.0",
"prettier": "^3.9.4",
"tailwindcss": "^4.0.0",
"typescript-eslint": "^8.65.0"
"@tailwindcss/cli": "^4.3.3",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript-eslint": "^8.67.0"
}
}
+5 -5
View File
@@ -16,10 +16,10 @@
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+4 -4
View File
@@ -18,10 +18,10 @@
"@wrnexus/ui": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+4 -4
View File
@@ -16,10 +16,10 @@
"@wrnexus/ui": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
"@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "^0.8.8",
"@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "^0.8.9"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
"@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "^0.8.8",
"@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "^0.8.9"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
+5 -5
View File
@@ -21,12 +21,12 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
@@ -12,6 +12,6 @@
"test": "bun test"
},
"dependencies": {
"@wrnexus/pubsub": "0.8.6"
"@wrnexus/pubsub": "^0.8.9"
}
}
+8 -4
View File
@@ -71,12 +71,16 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^10.8.0",
"happy-dom": "^20.11.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^10.8.1",
"happy-dom": "^20.11.2",
"prettier": "^3.9.6",
"react": "^19",
"react-dom": "^19",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
},
"engines": {
"bun": ">=1.3.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.11",
"version": "0.8.12",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
@@ -54,7 +54,7 @@
"devDependencies": {
"@types/bun": "^1.3.14",
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
},
"wrnexus": {
"plugin": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.8.10",
"version": "0.8.11",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -48,7 +48,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2",
"typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*"
},
"wrnexus": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.32",
"version": "0.8.46",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -28,6 +28,7 @@
"@wrnexus/db": "workspace:*",
"@wrnexus/authz": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/react": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*",
"@wrnexus/security": "workspace:*",
+75 -15
View File
@@ -24,9 +24,13 @@ import {
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
import { getIslandRuntime } from "@wrnexus/react/runtime";
import {
analyzeRuntimeImports,
analyzeRuntimeRequirements,
assertReactAvailable,
buildIslands,
islandNamesFrom,
assertValidAst,
generate,
generateTargets,
@@ -35,6 +39,7 @@ import {
type DeploymentRuntime,
runtimeCapabilities,
resolveWrnImports,
stripBrowserTypes,
} from "@wrnexus/compiler";
import {
loadAppConfig,
@@ -112,6 +117,7 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientModulesDir = join(distDir, "client");
const reactivePath = join(distDir, "reactive.js");
const controllersPath = join(distDir, "controllers.js");
const islandsPath = join(distDir, "islands.js");
const publicDir = join(root, "public");
const distPublicDir = join(distDir, "public");
const config = await loadAppConfig(root);
@@ -183,6 +189,8 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientFiles = new Map<string, string>();
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
const partialStaticFiles = new Set<string>();
/** Island component name -> resolved .tsx source, collected across all routes. */
const discoveredIslands = new Map<string, string>();
const compileWrn = async (file: string): Promise<void> => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8");
@@ -207,7 +215,25 @@ export async function runBuild(appRoot: string): Promise<void> {
assertValidAst(ast, { file, accessibility: true });
ast = await pluginRunner.transformAst(ast, file);
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
// Islands must be known before codegen (to emit markers instead of
// component mounts) and before route analysis (an island route ships JS).
const fileImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const fileIslands = islandNamesFrom(fileImports);
for (const imported of fileImports) {
if (imported.kind === "island" && imported.resolved) {
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
}
}
runtimeAnalysis.set(
file,
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
);
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
@@ -221,10 +247,11 @@ export async function runBuild(appRoot: string): Promise<void> {
try {
const targets = generateTargets(ast);
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let code =
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
code = await pluginRunner.transformCode(code, file);
browserCode = await pluginRunner.transformCode(browserCode, file);
@@ -247,11 +274,7 @@ export async function runBuild(appRoot: string): Promise<void> {
);
}
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const resolvedImports = fileImports;
for (const imported of resolvedImports) {
if (imported.diagnostic?.severity === "error") {
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
@@ -302,7 +325,9 @@ export async function runBuild(appRoot: string): Promise<void> {
}
writeFileSync(out, code, "utf8");
writeFileSync(clientEntry, browserCode, "utf8");
// The entry is .mjs, so anything TypeScript left in a client function
// body would be read back as JavaScript and fail to parse.
writeFileSync(clientEntry, stripBrowserTypes(browserCode), "utf8");
const browserResult = await Bun.build({
entrypoints: [clientEntry],
target: "browser",
@@ -493,6 +518,36 @@ export async function runBuild(appRoot: string): Promise<void> {
assetHash.update(controllerCode);
console.log(`✓ Controllers: ${controllersPath}`);
// Island bootstrap: emitted unconditionally but inert without markers, so a
// build with no islands still ships no React.
const islandCode = await buildBrowserRuntime(
getIslandRuntime(),
islandsPath,
join(compiledDir, "islands.entry.js"),
);
assetHash.update(islandCode);
console.log(`✓ Islands: ${islandsPath}`);
// Island bundles are emitted only when a route actually imported a .tsx, so a
// build with no islands produces no React and no island assets at all.
const islandsDir = join(distDir, "island");
if (discoveredIslands.size > 0) {
const missingReact = assertReactAvailable(root);
if (missingReact) {
throw new Error(`${missingReact.code}: ${missingReact.message}`);
}
mkdirSync(islandsDir, { recursive: true });
const islandBuild = await buildIslands({
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
outDir: islandsDir,
appRoot: root,
});
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
console.log(
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
);
}
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
const theme = resolveThemeConfig(config.theme, config.cookies);
const themeCss = renderThemeCss(theme);
@@ -568,10 +623,14 @@ export async function runBuild(appRoot: string): Promise<void> {
appDir,
appRoot: root,
mode: "production",
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
// Component discovery is not style discovery. Built-in and plugin
// components own their CSS; scanning every available component makes
// Tailwind/Iconify generate rules for packages and components the app
// never renders. Packages that intentionally use app utilities opt in
// through an explicit styles.source contribution.
sources: pluginContributions.styles.flatMap((style) =>
style.source ? [style.source] : [],
),
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
config.styles,
@@ -735,6 +794,7 @@ await createProductionServer(
reactivePath: join(import.meta.dir, "reactive.js"),
controllersPath: join(import.meta.dir, "controllers.js"),
clientModulesDir: join(import.meta.dir, "client"),
islandsDir: join(import.meta.dir, "island"),
themePath: join(import.meta.dir, "theme.css"),
themeAssetsDir: join(import.meta.dir, "theme"),
themeJsPath: join(import.meta.dir, "theme.js"),
+9 -9
View File
@@ -121,16 +121,16 @@ Thumbs.db
},
"devDependencies": {
"@wrnexus/cli": "${cliVersion}",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
+2 -2
View File
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
devDependencies: {
"@capacitor/cli": "^8.0.0",
"@capacitor/assets": "^3.0.0",
typescript: "^5.5.0",
typescript: "^6.0.3",
},
};
@@ -237,7 +237,7 @@ function generateNativeMobile(
"react-native-safe-area-context": "^5.6.0",
"react-native-screens": "^4.23.0",
},
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" },
};
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
+1 -1
View File
@@ -53,7 +53,7 @@ export function generateSystem(rootDir: string, input: string): string[] {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
},
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
wrnexus: {
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
},
+99 -1
View File
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
// Keep the CLI checker sourced from the package contract so typecheck fixes are
// included in each published CLI bundle.
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
import { parse } from "@wrnexus/syntax";
import { parse, type PageAst } from "@wrnexus/syntax";
import { generate, generateTargets } from "@wrnexus/compiler";
import { regenerateRoutes } from "./routes.ts";
import { loadAppConfig } from "@wrnexus/styles";
@@ -99,6 +99,78 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
);
}
/**
* Type assertions for sectioned api blocks.
*
* Enforcement lives here rather than in the compiler because this file is under
* `app/` and is therefore compiled by the project's own tsc, while generated
* build artifacts are not type-checked at all.
*/
function pageSlug(path: string): string {
// Block names are only unique within a single page (see apiBindingMap), so
// two pages each declaring e.g. `api search` is legal and would otherwise
// emit the identical `__wrn_api_check_search` type alias twice into this
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
// name with a short deterministic hash of the page's path (not the whole
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
// to keep names unique across the whole app while staying compact.
const normalized = path.replace(/\\/g, "/");
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
function apiBlockAssertions(
pages: { path: string; ast: PageAst }[],
apiContracts: string,
appDir: string,
): string {
const lines: string[] = [];
for (const page of pages) {
// Hash the path relative to `app/`, not the absolute path: the absolute
// path varies with where the project checkout lives (e.g. a CI runner's
// temp clone vs. a developer's local path), which would make this file
// spuriously "stale" every time it's regenerated somewhere else.
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
for (const block of page.ast.dataApis) {
if (!block.sections) continue;
// ssr-mode sectioned blocks can never declare a `request` (they are
// render-time only), so they always fall back to the empty-shape
// `Record<string, never>` below. `keyof Record<string, never>` is
// `string`, which makes the key-exactness arm of AssertAssignable
// evaluate to `false` unconditionally and raises TS2344 on every such
// block regardless of whether the block author did anything wrong.
// We choose to skip emission for both (a) any non-client-mode block,
// since it structurally can never have a request to check, and (b) any
// block -- client included -- that has zero declared request fields,
// since there is nothing to assert type-safety about. This is more
// honest about intent than emitting a vacuous/always-failing check.
const fields = [...block.sections.parameters, ...block.sections.body];
if (block.mode !== "client" || fields.length === 0) continue;
if (!apiContracts.includes(JSON.stringify(block.path))) {
console.warn(
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
);
}
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
lines.push(
`export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
block.path,
)}, ${JSON.stringify(block.method)}>>>;`,
);
}
}
return lines.join("\n");
}
export function generateApplicationTypes(
appRoot: string,
pluginContributions?: PluginContributions,
@@ -145,6 +217,10 @@ export function generateApplicationTypes(
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
})
.join("\n");
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
path: file,
ast: parse(readFileSync(file, "utf8")),
}));
const typeDir = join(app, "types");
const apiContracts = router.api
.map((route) => {
@@ -218,11 +294,33 @@ declare namespace WRNexusGenerated {
${generatedContractMap("RealtimeMessages", realtimeContracts)}
${generatedContractMap("QueuePayloads", queueContracts)}
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
type AssertAssignable<Actual, Expected> = unknown extends Expected
? true
: [Actual] extends [Expected]
? [Exclude<keyof Actual, keyof Expected>] extends [never]
? true
: false
: false;
type __wrn_expect_true<T extends true> = T;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
}
`;
mkdirSync(typeDir, { recursive: true });
const output = join(typeDir, "wrnexus.generated.d.ts");
writeFileSync(output, code, "utf8");
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
//
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
// checked; this file is compiled and checked normally by the project's own tsc.
${apiBlockAssertions(pageAsts, apiContracts, app)}
export {};
`;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
writePluginArtifacts(root, pluginContributions);
return {
file: relative(root, output).replace(/\\/g, "/"),
+8 -8
View File
@@ -13,7 +13,7 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
},
"devDependencies": {
"@wrnexus/cli": "${frameworkVersion}",
"@eslint/js": "^9.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
@@ -250,7 +250,7 @@ export default tseslint.config(
"test": "bun test"
},
"dependencies": {
"@wrnexus/pubsub": "${frameworkVersion}"
"@wrnexus/pubsub": "${scaffoldFrameworkRange}"
}
}
`,
+309
View File
@@ -0,0 +1,309 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { generateApplicationTypes } from "../src/types.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/** Minimal app with one typed endpoint and one page that calls it. */
function fixture(block: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
const BLOCK = ` api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
}
}
response {
return data.users
}
}`;
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
const root = fixture(BLOCK);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
expect(generated).toContain("type AssertAssignable<");
expect(generated).toContain('type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"]');
expect(generated).toContain(
'type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"]',
);
});
// The per-block assertions live in a plain .ts file, not the .d.ts: `skipLibCheck: true`
// (set repo-wide) exempts .d.ts *contents* from being checked at all, so a `.d.ts` can
// never actually enforce anything here. A real .ts file under app/ is compiled and
// checked normally.
test("emits one assertion per sectioned block, naming its route and method", () => {
const root = fixture(BLOCK);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
expect(checks).toContain("name?: string");
expect(checks).toContain("age?: number");
});
test("a legacy bare-body block produces no assertion", () => {
const root = fixture(` api legacyUsers GET /api/users {
return users.length
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
});
test("the api-checks file has no runtime code and is a module", () => {
const root = fixture(BLOCK);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).toContain("AUTO-GENERATED");
expect(checks.trim().endsWith("export {};")).toBe(true);
});
test("B1: two pages each declaring a block with the same name do not collide", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/one.wrn"),
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
);
writeFileSync(
join(root, "app/pages/two.wrn"),
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
expect(names.length).toBe(2);
expect(new Set(names).size).toBe(2);
});
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const GET = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/ssr.wrn"),
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("loadUsers");
});
test("B2: a client block with an empty request emits no assertion", () => {
const root = fixture(` api pingServer GET /api/users {
response {
return data.users
}
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("pingServer");
});
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
const root = fixture(BLOCK);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const assertionLine = checks
.split(/\r?\n/)
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
expect(assertionLine).toBeDefined();
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
});
// --- Real-compiler enforcement tests ---------------------------------------------
//
// Everything above only asserts on the emitted *text*. That proves nothing about
// whether the assertions actually make `tsc` fail — a build that reverted to the
// original inert `never`-based design, or one where `AssertAssignable` is merely
// one-directional (so it misses an *extra* declared field), would pass every test
// above unchanged. These tests instead run the real TypeScript compiler over the
// generated output and assert on its diagnostics.
//
// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback
// branch infers a real input type (`{ name: string; email: string }`) instead of
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
// could ever fail, which would make these tests meaningless.
function typedFixture(block: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`,
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
/**
* Compiles the two generated files (and whatever they reference on disk) with the
* real TypeScript compiler and returns its stdout plus whether it reported any
* diagnostics.
*/
function typecheckGenerated(root: string): { ok: boolean; output: string } {
const dts = join(root, "app/types/wrnexus.generated.d.ts");
const checks = join(root, "app/types/wrnexus.generated.api-checks.ts");
const result = Bun.spawnSync(
[
"bunx",
"tsc",
"--noEmit",
"--strict",
"--skipLibCheck",
"--moduleResolution",
"bundler",
"--target",
"ES2022",
"--module",
"ESNext",
dts,
checks,
],
{ cwd: root, stdout: "pipe", stderr: "pipe" },
);
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
return { ok: result.exitCode === 0, output };
}
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
request {
body {
name: string
email: string
}
}
response {
return data
}
}`;
test("tsc: a block whose fields match the contract has no diagnostics", () => {
const root = typedFixture(MATCHING_BLOCK);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(output.trim()).toBe("");
expect(ok).toBe(true);
});
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: number
email: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
});
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: string
email: string
extra: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
});
test("tsc: a missing required field fails", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
});
+1 -1
View File
@@ -147,7 +147,7 @@ test("scaffoldApp uses compatible framework packages and pins the current CLI",
}
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.123");
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"');
@@ -69,7 +69,9 @@ export default {
}
if (port === undefined) {
throw new Error(`production server failed to start: ${await new Response(proc.stderr).text()}`);
throw new Error(
`production server failed to start: ${await new Response(proc.stderr).text()}`,
);
}
const page = await fetch(`http://127.0.0.1:${port}/`);
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { currentCliVersion } from "../src/update-notifier.ts";
import { scaffoldFrameworkRange } from "../src/create.ts";
import {
addWorkspaceApp,
insertWorkspaceApp,
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com");
});
test("workspace templates pin the running framework release", () => {
test("workspace templates pin the CLI and use compatible independent package ranges", () => {
const files = workspaceFiles("acme");
const rootPackage = JSON.parse(files["package.json"]!);
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
const version = currentCliVersion();
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(scaffoldFrameworkRange);
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
expect(files["README.md"]).toContain("internal gateway targets");
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.10",
"version": "0.8.14",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -8,8 +8,8 @@
},
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/validation": "workspace:*"
}
}
+22 -1
View File
@@ -1,4 +1,5 @@
import type { PageAst, ViewNode } from "@wrnexus/syntax";
import type { ResolvedImport } from "./import-resolver.ts";
export type RouteExecutionKind =
| "static"
@@ -12,6 +13,8 @@ export interface RuntimeRequirements {
kind: RouteExecutionKind;
canPrerender: boolean;
needsClientRuntime: boolean;
/** True when the route mounts a React island and must ship the island runtime. */
needsIslandRuntime: boolean;
needsServerRuntime: boolean;
hydrationStrategy: string | null;
reasons: string[];
@@ -210,8 +213,21 @@ function hasEvent(nodes: ViewNode[]): boolean {
return false;
}
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
/**
* A route containing a React island ships JavaScript and can no longer be
* classified as zero-JS static, so island presence must reach the classifier.
*/
export function routeNeedsIslands(imports: ResolvedImport[]): boolean {
return imports.some((entry) => entry.kind === "island");
}
export function analyzeRuntimeRequirements(
ast: PageAst,
options: { hasIslands?: boolean } = {},
): RuntimeRequirements {
const hasIslands = options.hasIslands ?? false;
const reasons: string[] = [];
if (hasIslands) reasons.push("react island");
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
const clientState = ast.states.some((state) => state.runtime !== "server");
const interactive =
@@ -260,12 +276,17 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
reasons.push("partial-static shell with streamed dynamic regions");
}
// An island ships JavaScript, so a would-be zero-JS static route must be
// reported as static-interactive. Explicit render modes still win above.
if (hasIslands && kind === "static") kind = "static-interactive";
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
const serverDisabled = ast.renderMode === "client";
return {
kind,
canPrerender: kind === "static" || kind === "static-interactive",
needsIslandRuntime: hasIslands,
needsClientRuntime:
!clientDisabled &&
(interactive || ast.renderMode === "client") &&
@@ -0,0 +1,33 @@
/**
* Strip TypeScript from a generated browser module.
*
* A client function's body is emitted verbatim, so anything TypeScript-only
* inside one -- an annotated local, an `as` cast, a local interface -- reaches
* the browser module as TypeScript source. Codegen removes the types from the
* function's *signature*, which is what made this easy to miss: the emitted
* module looked transpiled, and only bodies carried types through.
*
* The artifact is written as `.mjs` and read back as plain JavaScript, so the
* failure surfaced as a syntax error pointing at generated code rather than at
* the `.wrn` line responsible.
*/
let transpiler: { transformSync(code: string): string } | null = null;
export function stripBrowserTypes(code: string): string {
const bun = (
globalThis as unknown as {
Bun?: { Transpiler: new (options: unknown) => { transformSync(code: string): string } };
}
).Bun;
if (!bun?.Transpiler) {
throw new Error(
"WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.",
);
}
transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" });
return transpiler.transformSync(code);
}
+42 -2
View File
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
"server",
"props",
"refs",
"api",
"event",
"payload",
]);
@@ -308,6 +309,32 @@ function _functionEntry(
}`;
}
/**
* Client-mode api blocks become members of an `api` object in client scope.
*
* Only the response and error bodies are emitted; the declared field types are
* type-only and are consumed by the types generator instead. Anything
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
*/
function apiBindings(ast: PageAst): string {
const members = ast.dataApis
.filter((block) => block.mode === "client" && block.sections)
.map((block) => {
const sections = block.sections!;
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
const error = eraseFunctionTypes(sections.error).trim();
const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw error; }`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
block.path,
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
export function generateBrowserModule(ast: PageAst): string {
const functions = ast.runtimeFunctions.filter((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
@@ -317,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
// `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter(
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
);
const sharedProps = ast.props
.map((entry) => entry.name)
.filter(
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name),
(name) =>
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
);
const callableAliases = functionNames.filter(
(name) =>
@@ -365,6 +404,7 @@ function __wrnexusCreateClientFunctions(context) {
const server = context.server;
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestore = () => { ${sharedRestore} };
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
+265 -14
View File
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import {
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding {
@@ -29,6 +35,10 @@ interface RenderBinding {
path: string;
body: string;
helpers: string;
// Present only for a sectioned ssr block with a non-empty `error {}` section.
// When set, a failed API call runs this body (with `status`/`message`/`data`
// bound) instead of propagating. Absent -> failures propagate, unchanged.
errorBody?: string;
}
interface SsrBinding extends RenderBinding {
@@ -43,6 +53,48 @@ interface NamedDataBinding extends RenderBinding {
mode: DataMode;
}
/**
* Island names for the file currently being generated.
*
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
* an extra parameter through every render function. Always reset in generate().
*/
let currentIslands: ReadonlySet<string> = new Set<string>();
/**
* Builds the island placeholder for a component tag that was imported from a
* .tsx file. Returns null for ordinary .wrn components.
*/
function islandMarkerFor(node: {
tag: string;
attrs: Array<{ name: string; value?: string }>;
}): string | null {
if (!currentIslands.has(node.tag)) return null;
const directives = node.attrs
.map((attr) => attr.name)
.filter((name) => name.startsWith("client:"));
const props: Record<string, unknown> = {};
for (const attr of node.attrs) {
if (attr.name.startsWith("client:")) continue;
const parsed = islandPropValue(attr.value);
if ("dynamic" in parsed) {
throw new Error(
`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
`Island props are serialized at build time, so they must be literal values ` +
`(for example start={3} or title="Revenue"), not ${attr.value}.`,
);
}
props[attr.name] = parsed.value;
}
const serialized = serializeIslandProps(node.tag, props);
if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message);
return renderIslandMarker({
name: node.tag,
strategy: parseIslandStrategy(directives),
propsJson: serialized.json,
});
}
function isComponentTag(tag: string): boolean {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -448,6 +500,8 @@ function renderLoopBody(node: ViewNode): string {
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island) return escLit(island);
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
@@ -543,7 +597,22 @@ function renderNode(
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
return `\x00WRNEACH${loops.length - 1}\x00`;
const definition =
node.type === "each"
? {
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
}
: node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
}));
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
}
if (node.tag === "Static" || node.tag === "Dynamic") {
@@ -704,6 +773,9 @@ function renderPageComponentInvocation(
loops: string[],
reactive: PageReactive | null,
): string {
const island = islandMarkerFor(node);
if (island) return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -720,6 +792,10 @@ function renderNestedComponentInvocation(
node: Extract<ViewNode, { type: "element" }>,
ctx: CompCtx,
): string {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island) return island;
let bindIndex = 0;
const attrs = node.attrs
@@ -801,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
path: binding.path,
body: binding.body,
helpers: binding.helpers,
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
};
}
@@ -864,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
if (bindings.has(block.name)) {
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
}
const sectioned = block.sections;
const errorSection = sectioned?.error.trim();
bindings.set(block.name, {
mode: block.mode,
method: block.method,
path: apiRoutePath(block.path),
body: dataBody(block.body),
// A sectioned block binds the payload to `data`; the legacy form keeps
// the `with ($data)` injection, which cannot be typed.
body: sectioned
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
: dataBody(block.body),
// Only a sectioned block with a non-empty `error {}` gets a fallback —
// legacy blocks and sectioned blocks without `error` keep failures
// propagating exactly as before.
...(errorSection ? { errorBody: errorSection } : {}),
helpers: modeHelpers(ast, block.mode, sharedHelpers),
});
}
@@ -877,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
}
function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
}
@@ -896,6 +983,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
}
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
const adapters = {
cookies: ctx.cookies,
session: ctx.session,
localStorage: ctx.localStorage,
};
const status = (err as { status?: unknown } | null | undefined)?.status;
const data = (err as { data?: unknown } | null | undefined)?.data;
const message = err instanceof Error ? err.message : String(err);
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
}
function __wrnexusPropAttr(
value: unknown,
): string {
@@ -925,18 +1024,53 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
const type = res.headers.get("content-type") || "";
if (!res.ok) {
throw new Error(".wrn data API request failed with status " + res.status);
const data = type.includes("application/json")
? await res.json().catch(() => undefined)
: await res.text().catch(() => undefined);
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
status: res.status,
data,
});
}
const type = res.headers.get("content-type") || "";
return type.includes("application/json") ? await res.json() : await res.text();
}
type __WrnexusApiCall = {
path: string;
method: string;
body: string;
helpers: string;
errorBody?: string;
};
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
// Shared by every ssr api-binding consumption site (marker replacement,
// #each loop consts, ...) so the narrow try/catch -- only active when the
// block declared an error section -- cannot drift between call sites.
async function __wrnexusResolveApiBinding(
binding: __WrnexusApiCall,
ctx: __WrnexusContext,
): Promise<unknown> {
if (binding.errorBody) {
let data: unknown;
try {
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
} catch (err) {
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
}
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
}
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
}
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
for (const binding of __wrnexusSsrBindings) {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
const value = await __wrnexusResolveApiBinding(binding, ctx);
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
}
return html;
@@ -1255,7 +1389,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<s
}
}
export function generate(ast: PageAst): string {
export interface GenerateOptions {
/** Local names bound to .tsx island imports in this file. */
islands?: ReadonlySet<string>;
}
export function generate(ast: PageAst, options: GenerateOptions = {}): string {
currentIslands = options.islands ?? new Set<string>();
try {
return generateInner(ast);
} finally {
currentIslands = new Set<string>();
}
}
function generateInner(ast: PageAst): string {
ast = optimizeAst(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") {
@@ -1431,8 +1579,11 @@ export function generate(ast: PageAst): string {
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} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
);
}
}
@@ -1440,7 +1591,9 @@ export function generate(ast: PageAst): string {
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
if (needsSsrRuntime) {
out.push(ssrRuntimeSource());
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
out.push(
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
);
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(
`export default async function ${ast.name}(ctx: __WrnexusContext) {
@@ -1966,6 +2119,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
return out + escLit(attrEscape(raw.slice(last)));
}
/**
* Serialize a control-block body as inert browser-side template markup.
* Values deliberately remain as mustaches: the CSR runtime evaluates them
* against the component scope (and `{#each}` locals) when it materializes the
* template. The string is base64 encoded before it is placed in HTML.
*/
function renderClientControlTemplate(nodes: ViewNode[]): string {
const render = (node: ViewNode): string => {
if (node.type === "text") {
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression: string) => {
const expression = rawExpression.trim();
return expression.startsWith("t:")
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
});
}
if (node.type === "each") {
return `<template data-wrn-each="${attrEscape(
encodeClientControl({
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
}),
)}"></template><template data-wrn-control-end></template>`;
}
if (node.type === "if") {
return `<template data-wrn-if="${attrEscape(
encodeClientControl(
node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
})),
),
)}"></template><template data-wrn-control-end></template>`;
}
const componentTag = isComponentTag(node.tag);
let bindIndex = 0;
const attrs = node.attrs
.map((attribute) => {
const name = attribute.event
? componentTag
? componentEventAttribute(attribute.name)
: eventAttribute(attribute.name)
: attribute.name;
if (attribute.boolean) return ` ${name}`;
if (attribute.name.startsWith("class:")) {
const expression = unwrapDirectiveExpression(attribute.value);
return ` data-wrn-class-${bindIndex++}="${attrEscape(
JSON.stringify([attribute.name.slice("class:".length), expression]),
)}"`;
}
if (attribute.name === "data-show") {
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
}
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
return attribute.value.includes("{")
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
JSON.stringify([name, attribute.value]),
)}"`
: rendered;
})
.join("");
const children = node.children.map(render).join("");
if (node.tag === "Static") return children;
if (componentTag)
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
};
return nodes.map(render).join("");
}
function encodeClientControl(value: unknown): string {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
}
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
let expression = "``";
@@ -1980,7 +2214,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
}
return "${" + expression + "}";
const definition = encodeClientControl(
node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
})),
);
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
}
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
@@ -1996,7 +2237,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
return (
const serverBody =
"${(() => { const __wl = Array.isArray(" +
list +
") ? (" +
@@ -2009,8 +2250,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
body +
'`).join("") : `' +
empty +
"`; })()}"
);
"`; })()}";
const definition = encodeClientControl({
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
});
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
}
function serverLoopLocalsAttribute(ctx: CompCtx): string {
+10 -1
View File
@@ -11,6 +11,8 @@ export interface ImportResolverOptions {
export interface ResolvedImport {
declaration: StructuredImportDecl;
resolved?: string;
/** `.tsx` imports are React islands, not `.wrn` components. */
kind?: "island";
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
}
@@ -21,9 +23,11 @@ function candidates(path: string): string[] {
path,
`${path}.wrn`,
`${path}.ts`,
`${path}.tsx`,
`${path}.d.ts`,
join(path, "index.wrn"),
join(path, "index.ts"),
join(path, "index.tsx"),
];
}
@@ -56,7 +60,12 @@ export function resolveWrnImport(
return false;
}
});
if (found) return { declaration, resolved: realpathSync(found) };
if (found) {
const resolved = realpathSync(found);
return resolved.endsWith(".tsx")
? { declaration, resolved, kind: "island" as const }
: { declaration, resolved };
}
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
return {
declaration,
+13
View File
@@ -31,6 +31,7 @@ export {
export { generate } from "./codegen.ts";
export { generateTargets } from "./targets.ts";
export { generateBrowserModule } from "./client-codegen.ts";
export { stripBrowserTypes } from "./browser-transpile.ts";
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
export { generateDeclarations } from "./type-codegen.ts";
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
@@ -126,3 +127,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
}
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
export {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts";
export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts";
export type { IslandBuildResult, IslandInput } from "./island-bundle.ts";
export { routeNeedsIslands } from "./analysis.ts";
+165
View File
@@ -0,0 +1,165 @@
import type { BunPlugin } from "bun";
import { createHash } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { basename, join } from "node:path";
export interface IslandInput {
name: string;
sourcePath: string;
}
export interface IslandBuildResult {
assets: Array<{ name: string; hash: string; path: string }>;
sharedChunks: string[];
}
/**
* Generates the per-island browser entry.
*
* Never imports react-dom/server islands are client-only.
*/
export function generateIslandEntry(input: IslandInput): string {
return [
"/** @jsxImportSource react */",
`import Component from ${JSON.stringify(input.sourcePath)};`,
`export const name = ${JSON.stringify(input.name)};`,
`export default Component;`,
"",
].join("\n");
}
/**
* Compiles every island `.tsx` against React's JSX runtime.
*
* The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
* island would otherwise compile to WRNexus's HTML-string renderer and
* silently never mount. A `@jsxImportSource` pragma applies only to the file
* that carries it, so putting one in the generated entry does nothing for the
* author's own component the injection has to happen per source file, which
* is what this plugin does. App-authored islands stay plain `.tsx`.
*/
export function reactJsxPlugin(): BunPlugin {
return {
name: "wrnexus-island-jsx",
setup(build) {
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
// Only first-party island sources need the pragma. Third-party .tsx
// under node_modules is left alone so Bun's own handling is untouched.
if (args.path.includes("node_modules")) return undefined;
return {
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
loader: "tsx" as const,
};
});
},
};
}
export function assertReactAvailable(
appRoot: string,
): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null {
const require = createRequire(join(appRoot, "package.json"));
try {
require.resolve("react");
require.resolve("react-dom");
return null;
} catch {
return {
code: "WRN-ISLAND-REACT-MISSING",
severity: "error",
message:
"This app imports a .tsx island but react and react-dom are not installed. " +
"Run: bun add react react-dom",
};
}
}
/**
* Bundles island entries. `splitting: true` is required so React is emitted
* once as a shared chunk rather than duplicated into every island.
*/
export async function buildIslands(input: {
islands: IslandInput[];
outDir: string;
/**
* App root used to resolve the island mount runtime. When given, the runtime
* is emitted as `runtime.js` in the SAME build as the islands.
*
* This is not a convenience: building the runtime separately gives it its own
* copy of React, and a component rendered by one copy while importing hooks
* from another fails with "Cannot read properties of null (reading
* 'useState')". One build with splitting keeps React in a single shared chunk.
*/
appRoot?: string;
}): Promise<IslandBuildResult> {
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
// Each island gets its own generated entry file named after the island.
// Passing the component sources directly would dedupe two islands that share
// a source file, and output order is not guaranteed to match input order —
// both of which silently mismatch island names to bundles.
//
// The entries live inside outDir so `react` resolves from the app that
// installed it, exactly as the island's own imports do.
const entryDir = join(input.outDir, ".entries");
mkdirSync(entryDir, { recursive: true });
const islandNames = new Set(input.islands.map((island) => island.name));
for (const island of input.islands) {
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
}
const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`));
if (input.appRoot) {
const resolveFrom = createRequire(join(input.appRoot, "package.json"));
const runtimeEntry = join(entryDir, "runtime.ts");
writeFileSync(
runtimeEntry,
`export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
`,
"utf8",
);
entrypoints.push(runtimeEntry);
}
try {
const result = await Bun.build({
entrypoints,
outdir: input.outDir,
target: "browser",
format: "esm",
splitting: true,
minify: true,
plugins: [reactJsxPlugin()],
});
if (!result.success) {
throw new AggregateError(result.logs, "Island bundling failed");
}
const assets: IslandBuildResult["assets"] = [];
const sharedChunks: string[] = [];
for (const output of result.outputs) {
if (output.kind === "entry-point") {
// Bun names an entry's output after its entry file, so the basename
// identifies the island unambiguously.
const stem = basename(output.path).replace(/\.js$/, "");
if (stem === "runtime") continue;
if (!islandNames.has(stem)) continue;
assets.push({
name: stem,
hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16),
path: output.path,
});
} else if (output.kind === "chunk") {
sharedChunks.push(output.path);
}
}
return { assets, sharedChunks };
} finally {
rmSync(entryDir, { recursive: true, force: true });
}
}
+141
View File
@@ -0,0 +1,141 @@
// Implemented locally rather than imported from @wrnexus/core: compiler modules
// are bundled into the Node-only VS Code extension, which contains no other
// packages, so a runtime import of core would break the editor compiler.
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!);
}
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
function isSafeIslandName(name: string): boolean {
return SAFE_ISLAND_NAME.test(name);
}
export type IslandStrategy = "only" | "load" | "visible" | "idle";
export interface IslandDiagnostic {
code: "WRN-ISLAND-PROPS";
message: string;
severity: "error";
}
const STRATEGIES: Record<string, IslandStrategy> = {
"client:only": "only",
"client:load": "load",
"client:visible": "visible",
"client:idle": "idle",
};
export function parseIslandStrategy(directives: string[]): IslandStrategy {
for (const directive of directives) {
const match = STRATEGIES[directive];
if (match) return match;
}
return "only";
}
function unsupportedProp(value: unknown): boolean {
const type = typeof value;
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
return true;
}
if (value === null || type !== "object") return false;
if (Array.isArray(value)) return (value as unknown[]).some(unsupportedProp);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return true;
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
}
/**
* Interprets an island attribute value with JSX semantics.
*
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
* `true`. Without this every prop arrives as a string, so `start={3}` would be
* `"3"` and arithmetic in the island silently concatenates.
*
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
* evaluated at runtime and cannot cross the serialization boundary.
*/
export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } {
if (raw === undefined || raw === "") return { value: true };
const expression = /^\{([\s\S]*)\}$/.exec(raw);
if (!expression) return { value: raw };
const inner = expression[1]!.trim();
try {
return { value: JSON.parse(inner) as unknown };
} catch {
return { dynamic: inner };
}
}
export function serializeIslandProps(
componentName: string,
props: Record<string, unknown>,
): { json: string } | { diagnostic: IslandDiagnostic } {
const offenders = Object.entries(props)
.filter(([, value]) => unsupportedProp(value))
.map(([key]) => key);
if (offenders.length > 0) {
return {
diagnostic: {
code: "WRN-ISLAND-PROPS",
severity: "error",
message:
`Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
`Island props cross a serialization boundary and must be JSON-safe ` +
`(no functions, symbols, bigints, undefined, or class instances).`,
},
};
}
return { json: JSON.stringify(props) };
}
export function renderIslandMarker(input: {
name: string;
strategy: IslandStrategy;
propsJson: string;
}): string {
// The name becomes a path segment when the browser fetches
// /__wrnexus/island/<name>.js, so reuse the framework's conservative charset
// rather than relying on escaping alone.
if (!isSafeIslandName(input.name)) {
throw new Error(
`Island name '${input.name}' is not a safe identifier. ` +
`Island names may only contain letters, digits, underscores, and hyphens.`,
);
}
return (
`<div data-wrn-island="${escapeHtml(input.name)}"` +
` data-wrn-island-strategy="${input.strategy}"` +
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
);
}
/**
* Local binding names introduced by island imports.
*
* Codegen sees only component tag names, so it needs the set of names that came
* from `.tsx` imports to tell an island apart from a `.wrn` component.
*/
export function islandNamesFrom(
imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>,
): Set<string> {
const names = new Set<string>();
for (const entry of imports) {
if (entry.kind !== "island") continue;
const local = entry.declaration.defaultImport;
if (local) names.add(local);
}
return names;
}
@@ -0,0 +1,285 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function browserModule(inner: string): string {
return generateTargets(
parse(`page Repro {
client {
${inner}
}
functions {
client async function run(): Promise<void> {
const users = await api.searchUsers({ name: "Ajay" })
console.log(users)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
const BLOCK = ` api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
}
}
response {
return data.users
}
error {
return []
}
}`;
test("emits an api member that calls the transport with the block's path and method", () => {
const generated = browserModule(BLOCK);
expect(generated).toContain("const api =");
expect(generated).toContain("searchUsers");
expect(generated).toContain('"/api/users"');
expect(generated).toContain('"POST"');
});
test("declared field types never reach the browser module", () => {
// The artifact is written as .mjs and parsed as JavaScript.
const generated = browserModule(BLOCK);
expect(generated).not.toContain("name?: string");
expect(generated).not.toContain("age?: number");
});
test("the emitted module is valid JavaScript", () => {
const generated = browserModule(BLOCK);
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a block without an error section still emits its response body", () => {
const generated = browserModule(` api plainUsers GET /api/users {
request {
parameters {
team: string
}
}
response {
return data.users
}
}`);
expect(generated).toContain("plainUsers");
expect(generated).toContain("data.users");
});
test("type annotations in response/error bodies are erased before emission (B4)", () => {
// Every other browser-bound body in the repo passes through eraseFunctionTypes
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
// store-codegen.ts); response/error bodies must too, for the same reason:
// eraseFunctionTypes strips function-signature annotations (params, return
// type, typed catch clauses) so a locally-declared helper function inside a
// response/error body no longer ships raw TypeScript into the .mjs artifact.
const generated = browserModule(` api searchUsers POST /api/users {
request {
body {
name?: string
}
}
response {
function pick(list: string[]): string[] { return list }
return pick(data.users)
}
error {
function describe(e: unknown): string { return String(e) }
return describe(error)
}
}`);
expect(generated).not.toContain("list: string[]");
expect(generated).not.toContain("): string[] {");
expect(generated).not.toContain("e: unknown");
expect(generated).not.toContain("): string {");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a page with state api and no client api blocks still reads that state (B5)", () => {
// "api" is normally excluded from state/prop destructuring because the
// emitted `const api = {...}` binding would shadow it -- but that binding
// only exists when the page has client-mode api blocks. Without one, the
// exclusion left `api` completely undeclared: a ReferenceError.
const generated = generateTargets(
parse(`page Repro {
state {
api = "hello"
}
functions {
client function run(): void {
console.log(api)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(generated).toContain("context.state");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
/**
* Builds a browser module whose `run()` function calls api.searchUsers and
* reports the outcome through `output.report(...)` so the test can observe
* whether the call resolved or rejected without reaching into codegen
* internals.
*/
function reportingBrowserModule(apiBlock: string): string {
return generateTargets(
parse(`page Repro {
client {
${apiBlock}
}
outputs {
report(payload: any)
}
functions {
client async function run(): Promise<void> {
try {
const users = await api.searchUsers({ name: "Ajay" })
output.report({ ok: true, users })
} catch (e) {
output.report({ ok: false, message: String(e && e.message || e) })
}
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
async function importBrowserModule(source: string): Promise<any> {
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.mjs");
writeFileSync(file, source);
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
}
test("a response body error is not swallowed by the error section (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users.missing.length
}
error {
return []
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => ({ users: [] }),
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
// The error section's own fallback ("[]" / an empty array) must not have
// been what the caller observed -- a bug in the response body is a
// rejection, not a silently-returned fallback value.
expect(reports[0]).not.toEqual({ ok: true, users: [] });
});
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users
}
error {
return ["fallback"]
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => {
throw Object.assign(new Error("transport failed"), { status: 500 });
},
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
});
test("a state field named api does not collide with the emitted api object", () => {
const generated = generateTargets(
parse(`page Repro {
state {
api = ""
}
client {
${BLOCK}
}
functions {
client async function run(): Promise<void> {
const users = await api.searchUsers({ name: "Ajay" })
console.log(users)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
@@ -0,0 +1,282 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
// unrelated TypeScript version that doesn't understand this repo's tsconfig
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
// which uses this same local binary.
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
// file that's actually invoked (our temp one), not the base file — so the
// ambient `bun` types need an explicit path back to the repo's node_modules.
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/**
* Runs the real TypeScript compiler over a generated server module. Proves
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
* module) actually type-checks string-containment assertions alone can't
* catch a declared type that omits a field every emitted object literal has.
*/
function typecheckGenerated(source: string): { ok: boolean; output: string } {
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
roots.push(root);
const file = join(root, "page.ts");
writeFileSync(file, source);
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
// checks the one file we care about instead of hand-duplicating the whole
// compiler configuration (and drifting from it over time).
writeFileSync(
join(root, "tsconfig.json"),
JSON.stringify({
extends: ROOT_TSCONFIG,
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
include: ["page.ts"],
}),
);
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
});
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
return { ok: result.exitCode === 0, output };
}
function serverModule(inner: string): string {
return generate(
parse(`page Repro {
ssr {
${inner}
}
view { <main><p api="ssrUsers">loading</p></main> }
}
`),
);
}
test("a sectioned ssr block binds the payload to data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).toContain("data.users.length");
});
test("a legacy ssr block is unchanged", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
return users.length
}`);
expect(generated).toContain("users.length");
});
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return message + status + data
}
}`);
expect(generated).toContain('"errorBody"');
expect(generated).toContain("return message + status + data");
expect(generated).toContain("const status = $status");
expect(generated).toContain("const message = $message");
expect(generated).toContain("const data = $data");
expect(generated).toContain("__wrnexusEvalError");
});
test("an ssr block without an error section emits no catch entry for that binding", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).not.toContain('"errorBody"');
});
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return message + status + data
}
}`);
const { ok, output } = typecheckGenerated(generated);
expect(output.trim()).toBe("");
expect(ok).toBe(true);
});
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block's response body error is not swallowed by the error section", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.missing.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => ({ users: [] }),
}),
).rejects.toThrow();
});
test("an ssr block still runs the error body on a genuine transport failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
});
@@ -0,0 +1,93 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts";
import { stripBrowserTypes } from "../src/browser-transpile.ts";
/** Build the browser module for a page whose client function body is TypeScript. */
function browserModuleFor(body: string): string {
const source = `page Repro {
functions {
client async function run(): Promise<void> {
${body}
}
}
view {
<main><button @click="run()">go</button></main>
}
}
`;
return generateTargets(parse(source)).browser;
}
/** The artifact is written as .mjs, so this is how the runtime reads it back. */
function parsesAsJavaScript(code: string): boolean {
try {
new Function(code.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
return true;
} catch {
return false;
}
}
test("a client function body keeps its TypeScript in the generated module", () => {
// Codegen strips the signature's types but copies the body verbatim, which is
// what made this easy to miss. Guarding the premise the fix rests on.
const generated = browserModuleFor(` const requestBody: Record<string, unknown> = {}`);
expect(generated).toContain("const requestBody: Record<string, unknown>");
expect(parsesAsJavaScript(generated)).toBe(false);
});
test("stripping types makes an annotated client function body valid JavaScript", () => {
const stripped = stripBrowserTypes(
browserModuleFor(` const requestBody: Record<string, unknown> = {}
requestBody.q = "x"`),
);
expect(parsesAsJavaScript(stripped)).toBe(true);
expect(stripped).not.toContain("Record<string, unknown>");
expect(stripped).toContain("requestBody.q");
});
test("casts, generics and local interfaces survive stripping", () => {
const stripped = stripBrowserTypes(
browserModuleFor(` interface Local { a: string }
const names: string[] = ["a"]
const typed = { a: "x" } as Local
const total = (1 as number) + names.length
console.log(typed.a, total)`),
);
expect(parsesAsJavaScript(stripped)).toBe(true);
expect(stripped).toContain("console.log");
expect(stripped).not.toContain("interface Local");
});
test("the module's exported bindings are preserved", () => {
// A transpile that dropped one of these would break hydration silently.
const stripped = stripBrowserTypes(
browserModuleFor(` const value: number = 1
console.log(value)`),
);
for (const binding of [
"__wrnexusClientFunctions",
"__wrnexusClientState",
"__wrnexusOutputs",
"__wrnexusImportedBindings",
"bindClientScope",
]) {
expect(stripped).toContain(binding);
}
});
test("a body with no TypeScript is left working", () => {
const stripped = stripBrowserTypes(
browserModuleFor(` const plain = { a: 1 }
console.log(plain.a)`),
);
expect(parsesAsJavaScript(stripped)).toBe(true);
expect(stripped).toContain("console.log");
});
+17
View File
@@ -1053,6 +1053,23 @@ component Banner {
expect(output).toContain("Visible");
expect(output).toContain("Hidden");
});
test("if and each blocks emit browser control metadata while preserving SSR", () => {
const output = generate(
parse(`component ClientBlocks {
state open = false
state items = ["a"]
view {
{#if open}<p>Open</p>{:else}<p>Closed</p>{/if}
{#each items as item}<span>{item}</span>{:empty}<i>Empty</i>{/each}
}
}`),
);
expect(output).toContain("data-wrn-if=");
expect(output).toContain("data-wrn-each=");
expect(output).toContain("Array.isArray(items)");
});
test("component array props support each blocks", () => {
const output = generate(
parse(`
@@ -0,0 +1,61 @@
import { afterAll, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assertReactAvailable, buildIslands, generateIslandEntry } from "../src/island-bundle.ts";
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
test("generates an entry that re-exports the island component", () => {
const entry = generateIslandEntry({ name: "Chart", sourcePath: "/app/Chart.tsx" });
expect(entry).toContain("/app/Chart.tsx");
expect(entry).toContain("Chart");
expect(entry).not.toContain("react-dom/server");
});
test("island .tsx compiles against React's JSX runtime, not WRNexus's", async () => {
// The root tsconfig points jsxImportSource at @wrnexus/core, so an island
// would otherwise compile to WRNexus's HTML-string renderer and never mount.
// A pragma applies only to the file carrying it, so this asserts the built
// output rather than the generated entry text.
// The fixture must live inside the repo: Bun resolves `react` from the
// importing file's location, exactly as a real island resolves it from the
// app that installed react.
const root = mkdtempSync(join(process.cwd(), ".island-jsx-test-"));
created.push(root);
const source = join(root, "Chart.tsx");
writeFileSync(
source,
`export default function Chart({ title }: { title: string }) {
return <div className="chart">{title}</div>;
}`,
);
const outDir = join(root, "out");
await buildIslands({ islands: [{ name: "Chart", sourcePath: source }], outDir });
const built = readdirSync(outDir)
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(outDir, file), "utf8"))
.join("\n");
expect(built).not.toContain("wrnexus");
expect(built).toMatch(/react\/jsx|jsxDEV|jsx_runtime/);
});
test("reports WRN-ISLAND-REACT-MISSING when react is not installed", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-noreact-"));
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
const diagnostic = assertReactAvailable(root);
expect(diagnostic?.code).toBe("WRN-ISLAND-REACT-MISSING");
expect(diagnostic?.message).toContain("bun add react react-dom");
});
test("returns null when react resolves", () => {
expect(assertReactAvailable(process.cwd())).toBeNull();
});
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { analyzeRuntimeRequirements, routeNeedsIslands } from "../src/analysis.ts";
test("a route with an island import needs client JavaScript", () => {
expect(
routeNeedsIslands([
{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" },
{ declaration: { source: "./Chart" } as any, resolved: "/app/Chart.tsx", kind: "island" },
]),
).toBe(true);
});
test("a route with no island imports stays zero-JS", () => {
expect(
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
).toBe(false);
});
test("an empty import list stays zero-JS", () => {
expect(routeNeedsIslands([])).toBe(false);
});
test("an unresolved import does not count as an island", () => {
expect(
routeNeedsIslands([
{
declaration: { source: "./missing" } as any,
diagnostic: { code: "WRN-IMPORT-NOT-FOUND", message: "nope", severity: "warning" },
},
]),
).toBe(false);
});
test("an island promotes a static route to static-interactive", () => {
const source = `page Home { view { <div>hello</div> } }`;
const ast = parse(source);
const plain = analyzeRuntimeRequirements(ast);
expect(plain.kind).toBe("static");
expect(plain.needsIslandRuntime).toBe(false);
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
expect(withIsland.kind).toBe("static-interactive");
expect(withIsland.needsIslandRuntime).toBe(true);
expect(withIsland.reasons).toContain("react island");
});
test("an island does not turn on the WRNexus reactive runtime", () => {
const ast = parse(`page Home { view { <div>hello</div> } }`);
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
// Islands ship the island runtime, not WRNexus's own client runtime.
expect(withIsland.needsClientRuntime).toBe(false);
expect(withIsland.needsIslandRuntime).toBe(true);
});
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test";
import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "../src/island-codegen.ts";
test("defaults to the client-only strategy", () => {
expect(parseIslandStrategy([])).toBe("only");
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
expect(parseIslandStrategy(["client:load"])).toBe("load");
});
test("serializes JSON-safe props", () => {
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
});
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
const result = serializeIslandProps("Chart", { onClick: () => {} });
expect(result).toHaveProperty("diagnostic");
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
expect(diagnostic.message).toContain("Chart");
expect(diagnostic.message).toContain("onClick");
});
test("rejects class instances and nested offenders", () => {
class Point {
constructor(public x = 1) {}
}
expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic");
expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty(
"diagnostic",
);
expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic");
});
test("accepts null and nested plain data", () => {
const result = serializeIslandProps("Chart", {
empty: null,
nested: { rows: [{ id: 1 }], flag: false },
});
expect(result).toHaveProperty("json");
});
test("rejects island names that are unsafe as URL path segments", () => {
// The name is fetched as /__wrnexus/island/<name>.js, so traversal and
// separators must be refused rather than merely escaped.
expect(() =>
renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }),
).toThrow(/not a safe identifier/);
expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow(
/not a safe identifier/,
);
expect(() =>
renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }),
).not.toThrow();
});
test("renders a marker with escaped props", () => {
const html = renderIslandMarker({
name: "Chart",
strategy: "visible",
propsJson: '{"title":"a<b\\"c"}',
});
expect(html).toContain('data-wrn-island="Chart"');
expect(html).toContain('data-wrn-island-strategy="visible"');
expect(html).not.toContain('title":"a<b"c');
expect(html).toContain("&lt;");
expect(html).toContain("&quot;");
});
test("collects local binding names from island imports only", () => {
const names = islandNamesFrom([
{ kind: "island", declaration: { defaultImport: "Chart" } },
{ declaration: { defaultImport: "Card" } },
{ kind: "island", declaration: {} },
]);
expect([...names]).toEqual(["Chart"]);
});
test("island prop values follow JSX semantics, not raw attribute strings", () => {
// Without this, start={3} arrives as the string "3" and arithmetic inside the
// island concatenates: 3 -> "31" -> "311".
expect(islandPropValue("{3}")).toEqual({ value: 3 });
expect(islandPropValue("{true}")).toEqual({ value: true });
expect(islandPropValue("{[1,2]}")).toEqual({ value: [1, 2] });
expect(islandPropValue('{"a"}')).toEqual({ value: "a" });
expect(islandPropValue("Revenue")).toEqual({ value: "Revenue" });
expect(islandPropValue(undefined)).toEqual({ value: true });
});
test("a runtime expression prop is reported as dynamic", () => {
expect(islandPropValue("{someVariable}")).toEqual({ dynamic: "someVariable" });
expect(islandPropValue("{fn()}")).toEqual({ dynamic: "fn()" });
});
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const SOURCE = `page Home {
view {
<Chart title="Revenue" client:visible />
<Card>plain</Card>
}
}`;
test("an island tag emits an island marker instead of a component mount", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).toContain("Chart");
expect(out).toContain('data-wrn-island-strategy="visible"');
// Non-island components still mount the normal way.
expect(out).toContain('data-component="Card"');
});
test("without the island set the same tag stays a normal component", () => {
const out = generate(parse(SOURCE));
expect(out).not.toContain("data-wrn-island=");
expect(out).toContain('data-component="Chart"');
});
test("island props are serialized into the marker", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("Revenue");
});
test("numeric and boolean island props keep their types through the marker", () => {
const source = `page Home {
view { <Chart start={3} live={true} title="Revenue" /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("&quot;start&quot;:3");
expect(out).toContain("&quot;live&quot;:true");
expect(out).toContain("&quot;title&quot;:&quot;Revenue&quot;");
});
test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
const source = `page Home {
state count = 1
view { <Chart value={count} /> }
}`;
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
/WRN-ISLAND-PROPS/,
);
});
test("an island inside a .wrn component also emits a marker", () => {
const source = `component Panel {
view { <Chart start={1} /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).not.toContain('data-component="Chart"');
});
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { routeNeedsIslands } from "../src/analysis.ts";
import { buildIslands, type IslandBuildResult } from "../src/island-bundle.ts";
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
function outDir(label: string): string {
const dir = mkdtempSync(join(process.cwd(), `.island-int-${label}-`));
created.push(dir);
return dir;
}
// Every assertion that needs a real bundle shares this one build.
//
// Not just for speed: `bun test` interferes with Bun.build's module reads once
// several build calls have run across test files in the same process, while the
// same calls succeed repeatedly outside the runner. Production is unaffected —
// the dev server's rebuild loop was verified separately — but tests must keep
// their build count low to stay reliable in the full suite.
let dir: string;
let result: IslandBuildResult;
let bundles: string[];
beforeAll(async () => {
dir = outDir("shared");
result = await buildIslands({
islands: [
{ name: "CounterA", sourcePath: COUNTER },
{ name: "CounterB", sourcePath: COUNTER },
],
outDir: dir,
});
bundles = readdirSync(dir)
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(dir, file), "utf8"));
});
test("a route with no islands ships zero framework JavaScript", async () => {
const empty = outDir("nojs");
const none = await buildIslands({ islands: [], outDir: empty });
expect(none.assets).toHaveLength(0);
expect(none.sharedChunks).toHaveLength(0);
expect(readdirSync(empty)).toHaveLength(0);
expect(routeNeedsIslands([])).toBe(false);
});
test("a page with multiple islands ships React exactly once", () => {
// React's internals must appear in at most one emitted file — the shared
// chunk. If splitting regresses, every island inlines its own copy.
const withReactInternals = bundles.filter(
(source) => source.includes("REACT_ELEMENT_TYPE") || source.includes("react.development"),
);
expect(result.assets).toHaveLength(2);
expect(withReactInternals.length).toBeLessThanOrEqual(1);
});
test("two islands sharing one source get distinct, correctly named assets", () => {
// Passing component sources as entrypoints deduped them, so the second island
// silently lost its bundle and names could bind to the wrong output.
expect(result.assets.map((asset) => asset.name).sort()).toEqual(["CounterA", "CounterB"]);
expect(new Set(result.assets.map((asset) => asset.path)).size).toBe(2);
});
test("a real island builds against React and never pulls in the WRNexus renderer", () => {
const built = bundles.join("\n");
expect(built).not.toContain("wrnexus");
expect(built).not.toContain("react-dom/server");
expect(built).toContain("useState");
});
test("the generated entry directory is not left behind in the output", () => {
expect(readdirSync(dir)).not.toContain(".entries");
});
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveWrnImport } from "../src/import-resolver.ts";
function appWith(files: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), "wrnexus-island-"));
mkdirSync(join(root, "app"), { recursive: true });
for (const [name, contents] of Object.entries(files)) {
writeFileSync(join(root, "app", name), contents);
}
return root;
}
test("resolves a .tsx import and tags it as an island", () => {
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
const result = resolveWrnImport(
{ source: "./Chart", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Chart.tsx");
expect(result.kind).toBe("island");
});
test("does not tag a .ts import as an island", () => {
const root = appWith({ "helper.ts": "export const value = 1;" });
const result = resolveWrnImport(
{ source: "./helper", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("helper.ts");
expect(result.kind).toBeUndefined();
});
test("prefers .wrn over .tsx when both exist", () => {
const root = appWith({
"Widget.wrn": "<template></template>",
"Widget.tsx": "export default function Widget() { return null; }",
});
const result = resolveWrnImport(
{ source: "./Widget", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Widget.wrn");
expect(result.kind).toBeUndefined();
});
test("resolves an explicit .tsx extension as an island", () => {
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
const result = resolveWrnImport(
{ source: "./Chart.tsx", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.kind).toBe("island");
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/content",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
"main": "./src/index.ts",
@@ -14,6 +14,6 @@
],
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.9",
"version": "0.8.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+15 -1
View File
@@ -96,7 +96,21 @@ export function defineEndpoint(
if (definition.auth === "required" && !ctx.user) {
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
}
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput;
// The real HTTP router invokes route handlers as `handler(ctx)` — it never
// supplies a second argument. Callers that already have a parsed payload
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
// that always wins. Otherwise, read the request ourselves: query params for
// GET/HEAD, JSON body for everything else.
let input: unknown = rawInput;
if (definition.input) {
const resolvedInput =
rawInput !== undefined
? rawInput
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
? Object.fromEntries(ctx.url.searchParams)
: await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput);
}
const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output });
+105
View File
@@ -29,3 +29,108 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
});
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
// passes rawInput explicitly, so it never exercises that calling convention. These
// tests call the endpoint with only a context, matching what actually happens in
// production, to guard against silently validating `undefined` again.
const search = v.object({ name: v.string().trim().optional() });
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
input: search,
handler(input) {
return { name: input.name ?? null };
},
});
test("with no second argument, a GET request reads input from the URL's query string", async () => {
const request = new Request("https://example.test/api/search?name=Ada");
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
const malformedRequest = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
});
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
const malformedResponse = await searchEndpoint(malformedCtx);
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
// the point is that the malformed body did not throw an unhandled parse error.
expect(malformedResponse.status).toBe(200);
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
const requiredField = v.object({ name: v.string().min(1) });
const requiredEndpoint = defineEndpoint({
input: requiredField,
handler(input) {
return input;
},
});
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
const emptyResponse = await requiredEndpoint(emptyCtx);
// With no body at all, resolved input is {} — the schema's own required-field
// validation is what turns that into a 400, not a thrown parse error.
expect(emptyResponse.status).toBe(400);
expect(await emptyResponse.json()).toEqual({
error: {
code: "VALIDATION_ERROR",
message: "Endpoint validation failed.",
details: { name: "Required" },
},
});
});
// GET query strings travel as text (`URLSearchParams` values are always
// strings), so a `v.number()` field must come back as a real number, not the
// string the wire actually carried, or a page declaring `age?: number` on a
// GET api block would be lying about the type. checkField in
// @wrnexus/validation coerces via Number(pre) for both optional and required
// number fields (see packages/validation/src/index.ts); this locks that in
// end-to-end through defineEndpoint's own GET query-string resolution path.
test("a GET request coerces a v.number() query param to an actual number", async () => {
const ageSchema = v.object({ age: v.number() });
const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({
input: ageSchema,
handler(input) {
return { age: input.age, typeofAge: typeof input.age };
},
});
const request = new Request("https://example.test/api/age?age=30");
const ctx = createContext(request, new URL(request.url));
const response = await ageEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } });
});
test("an explicit rawInput argument still wins and the request is never read", async () => {
// A request whose body has already been consumed: if the endpoint tried to read it
// again (rather than trusting the explicit rawInput), this would throw.
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "ignored-body" }),
});
await request.json(); // drain the body so a second .json() call would reject
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx, { name: "Explicit" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.19",
"version": "0.8.25",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
var emitPinInputEvent = bridge.emitPinInputEvent;
var parseScopeDecl = bridge.parseScopeDecl;
var warnOnce = bridge.warn || function () {};
// The extracted sections use these the same way the core runtime does, and
// this bundle is a separate IIFE, so it needs its own copies.
function hasOwn(target, key) {
return Object.prototype.hasOwnProperty.call(target, key);
}
function toArray(value) {
return Array.prototype.slice.call(value);
}
${sections}
function hydrate(root) {
var host = root || document;
+43
View File
@@ -367,6 +367,43 @@ export const NAV_RUNTIME = String.raw`
});
}
function syncI18n(nextDocument) {
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
if (!script) return;
try {
var incoming = JSON.parse(String(script.textContent || "{}"));
var current = window.__wrnI18n || {};
var translator = current.t;
var setter = current.set;
function mergeCatalog(base, update) {
var output = {};
Object.keys(base && typeof base === "object" ? base : {}).forEach(function (key) {
var value = base[key];
output[key] = value && typeof value === "object" && !Array.isArray(value)
? mergeCatalog(value, {})
: value;
});
Object.keys(update && typeof update === "object" ? update : {}).forEach(function (key) {
var left = output[key];
var right = update[key];
output[key] = left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)
? mergeCatalog(left, right)
: right;
});
return output;
}
if (current.lang && current.lang === incoming.lang) {
incoming.messages = mergeCatalog(current.messages, incoming.messages);
incoming.fallbackMessages = mergeCatalog(current.fallbackMessages, incoming.fallbackMessages);
}
window.__wrnI18n = incoming;
if (translator) window.__wrnI18n.t = translator;
if (setter) window.__wrnI18n.set = setter;
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n data", error);
}
}
/**
* Run explicit component cleanup before removing the existing page.
*
@@ -516,6 +553,8 @@ export const NAV_RUNTIME = String.raw`
syncPreservationPolicy(doc);
syncI18n(doc);
syncWrnStyles(doc);
var importedNodes = [];
@@ -568,6 +607,10 @@ export const NAV_RUNTIME = String.raw`
*/
rehydrate(currentApp);
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
window.__wrnLang.bind(currentApp);
}
if (!isPop) {
history.pushState(
{
+466 -214
View File
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
var behaviorObserver;
var clientModuleCache = new Map();
/*
* Two builtin chains the runtime reaches for constantly. Aliasing them is
* not only shorter: hasOwn keeps prototype keys from reading as data, and
* toArray is needed because a NodeList is not an Array.
*/
function hasOwn(target, key) {
return Object.prototype.hasOwnProperty.call(target, key);
}
function toArray(value) {
return Array.prototype.slice.call(value);
}
/*
* data-wrn-class-* and data-wrn-bind-* both carry a JSON ["name","expression"]
* pair. Malformed markup yields null so every caller bails the same way
* rather than each repeating the parse and the shape check.
*/
function pairBinding(value) {
var parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
return null;
}
return parsed && parsed.length === 2 ? parsed : null;
}
/*
* Globals the expression engine resolves for client code. Kept as explicit
* tables rather than falling through to window[name]: an implicit fallback
* lists rather than falling through to window[name]: an implicit fallback
* would let any expression reach every global on the page (and would make a
* typo silently resolve to some unrelated window property) -- these lists
* say exactly what client code may reach.
*
* dialogGlobals must be bound to window or the browser throws
* "Illegal invocation" when they are called detached.
* Prototype-less so a name like "toString" or "constructor" is a miss
* rather than a hit on Object.prototype.
*/
var dialogGlobals = {
alert: 1,
confirm: 1,
prompt: 1,
fetch: 1,
print: 1,
open: 1,
scrollTo: 1,
scrollBy: 1,
matchMedia: 1,
getComputedStyle: 1,
structuredClone: 1,
queueMicrotask: 1,
btoa: 1,
atob: 1,
};
function nameSet(names) {
var set = Object.create(null);
// Language builtins. Wrapped in thunks so referencing one that a given
// engine lacks cannot throw at table-definition time.
var jsGlobals = {
Object: function () { return Object; },
Boolean: function () { return Boolean; },
RegExp: function () { return RegExp; },
Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
Set: function () { return typeof Set === "undefined" ? undefined : Set; },
Map: function () { return typeof Map === "undefined" ? undefined : Map; },
Error: function () { return Error; },
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
parseInt: function () { return parseInt; },
parseFloat: function () { return parseFloat; },
isNaN: function () { return isNaN; },
isFinite: function () { return isFinite; },
encodeURIComponent: function () { return encodeURIComponent; },
decodeURIComponent: function () { return decodeURIComponent; },
encodeURI: function () { return encodeURI; },
decodeURI: function () { return decodeURI; },
NaN: function () { return NaN; },
Infinity: function () { return Infinity; },
undefined: function () { return undefined; },
};
names.split(" ").forEach(function (name) {
set[name] = 1;
});
return set;
}
/*
* Called with window as the receiver. Detached, the browser throws
* "Illegal invocation" for these.
*/
var boundWindowGlobals = nameSet(
"alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
" getComputedStyle structuredClone queueMicrotask btoa atob" +
" setTimeout clearTimeout setInterval clearInterval" +
" requestAnimationFrame cancelAnimationFrame",
);
/*
* Language builtins and other realm globals, read off globalThis. Naming
* them rather than referencing them directly means one an engine lacks
* resolves to undefined instead of throwing where the table is defined.
*/
var ambientGlobals = nameSet(
"Object Boolean RegExp Promise Set Map Error Symbol BigInt Intl parseInt" +
" parseFloat isNaN isFinite encodeURIComponent decodeURIComponent" +
" encodeURI decodeURI NaN Infinity undefined Array Number String Math" +
" JSON Date URL",
);
/*
* toast(...) -- raise a notification from any client expression.
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
if (!window.toast) window.toast = toastApi;
// Read straight off window, no binding needed (objects, not functions).
var windowGlobals = {
localStorage: 1,
sessionStorage: 1,
screen: 1,
performance: 1,
crypto: 1,
CustomEvent: 1,
Event: 1,
FormData: 1,
URLSearchParams: 1,
AbortController: 1,
Notification: 1,
IntersectionObserver: 1,
ResizeObserver: 1,
MutationObserver: 1,
devicePixelRatio: 1,
innerWidth: 1,
innerHeight: 1,
scrollX: 1,
scrollY: 1,
};
var windowGlobals = nameSet(
"localStorage sessionStorage screen performance crypto CustomEvent Event" +
" FormData URLSearchParams AbortController Notification" +
" IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
" innerWidth innerHeight scrollX scrollY location history navigator",
);
function reportDiagnostic(code, message, element, detail) {
var payload = {
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
var serverProxy = new Proxy({}, {
get: function (_target, property) {
return function () {
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
return callServerFunction(componentRpcName, String(property), toArray(arguments));
};
},
});
@@ -1111,7 +1116,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (name === "server") return serverProxy;
if (name === "props") return propsProxy;
if (name === "refs") return refsProxy;
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
if (hasOwn(moduleBindings, name)) return moduleBindings[name];
if (name === "$emit") {
return function (eventName, detail) {
return dispatchComponentEvent(componentEventTarget, eventName, detail);
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
if (name === "window") return window;
if (name === "document") return document;
if (name === "console") return console;
if (name === "Array") return Array;
if (name === "Number") return Number;
if (name === "String") return String;
if (name === "Math") return Math;
if (name === "JSON") return JSON;
if (name === "Date") return Date;
if (name === "URL") return URL;
if (name === "location") return window.location;
if (name === "history") return window.history;
if (name === "navigator") return window.navigator;
if (name === "$route" || name === "route") {
if (currentRenderer) routeValue.subscribe(currentRenderer);
return routeValue.get();
}
if (name === "setTimeout") return window.setTimeout.bind(window);
if (name === "clearTimeout") return window.clearTimeout.bind(window);
if (name === "setInterval") return window.setInterval.bind(window);
if (name === "clearInterval") return window.clearInterval.bind(window);
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
/*
* Ordinary browser and language globals.
*
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
* lacks one of these does not break the rest.
*/
if (name === "toast") return toastApi;
if (dialogGlobals[name] && typeof window[name] === "function") {
if (boundWindowGlobals[name] && typeof window[name] === "function") {
return window[name].bind(window);
}
if (jsGlobals[name]) {
var builtin = jsGlobals[name]();
if (ambientGlobals[name]) {
var builtin = globalThis[name];
if (builtin !== undefined) return builtin;
}
if (windowGlobals[name]) {
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
function readScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
if (hasOwn(computedDefinitions, name)) {
if (computing.has(name)) {
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
return undefined;
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
if (currentRenderer) sig.subscribe(currentRenderer);
return sig.get();
}
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name];
}
return readGlobal(name);
}
function peekScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
if (hasOwn(computedDefinitions, name)) {
return readScope(name);
}
if (signals[name]) return signals[name].get();
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name];
}
return readGlobal(name);
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
function evalExpr(expr, locals) {
return evaluateExpression(expr, function (name) {
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
if (locals && hasOwn(locals, name)) {
return locals[name];
}
return readScope(name);
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
source,
locals,
) {
locals = locals || Object.create(null);
return batchUpdates(function () {
var statements =
splitStatements(source);
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
hasOwn(
locals,
name,
)
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name, value) {
if (
locals &&
Object.prototype
.hasOwnProperty.call(
hasOwn(
locals,
name,
)
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
locals,
);
},
function (name, value) {
locals[name] = value;
},
);
if (result.returned) {
@@ -1388,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
state: stateProxy,
output: outputProxy,
server: serverProxy,
callApi: wrnexusCallApi,
props: propsProxy,
refs: refsProxy,
};
@@ -1501,19 +1494,13 @@ export const REACTIVE_RUNTIME = String.raw`
}
return type + ":" + String(value);
}
function fillMustache(str, itemEval) {
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
var e = (d || s).trim();
try { return String(itemEval(e)); } catch (err) { return ""; }
});
}
function hydrateItem(
root,
locals,
) {
function localRead(name) {
if (
Object.prototype.hasOwnProperty.call(
hasOwn(
locals,
name,
)
@@ -1597,7 +1584,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (node !== root && insideNestedLoop(node)) return;
var attributes =
Array.prototype.slice.call(
toArray(
node.attributes,
);
@@ -1611,24 +1598,21 @@ export const REACTIVE_RUNTIME = String.raw`
if (
attribute.name === "data-text"
) {
try {
var textValue =
itemEval(attribute.value);
node.textContent =
textValue == null
? ""
: String(textValue);
} catch (error) {
console.error(
"[wrnexus] data-for text binding failed for '" +
attribute.value +
"'",
error,
);
node.textContent = "";
}
(function (textNode, textExpression) {
var runText = reactive(function () {
try {
var textValue = itemEval(textExpression);
textNode.textContent = textValue == null ? "" : String(textValue);
} catch (error) {
console.error(
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
error,
);
textNode.textContent = "";
}
});
runText();
})(node, attribute.value);
return;
}
@@ -1642,28 +1626,12 @@ export const REACTIVE_RUNTIME = String.raw`
"data-wrn-class-",
) === 0
) {
var classBinding;
var classBinding = pairBinding(attribute.value);
try {
classBinding = JSON.parse(
attribute.value,
);
} catch (_) {
return;
}
if (!classBinding) return;
if (
!classBinding ||
classBinding.length !== 2
) {
return;
}
var className =
classBinding[0];
var classExpression =
classBinding[1];
var className = classBinding[0];
var classExpression = classBinding[1];
var classEnabled = false;
@@ -1693,28 +1661,13 @@ export const REACTIVE_RUNTIME = String.raw`
) === 0
) {
node.removeAttribute(attribute.name);
var binding;
try {
binding = JSON.parse(
attribute.value,
);
} catch (_) {
return;
}
var binding = pairBinding(attribute.value);
if (
!binding ||
binding.length !== 2
) {
return;
}
if (!binding) return;
var attributeName =
binding[0];
var attributeTemplate =
binding[1];
var attributeName = binding[0];
var attributeTemplate = binding[1];
/*
* Reactive, not resolved once. The expression can read component
@@ -1782,7 +1735,7 @@ export const REACTIVE_RUNTIME = String.raw`
eventLocals.event = event;
eventLocals.$event = event;
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
eventLocals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -1934,8 +1887,7 @@ export const REACTIVE_RUNTIME = String.raw`
// Hand every nested loop its own renderer, with this item in scope.
if (root.querySelectorAll) {
Array.prototype.slice
.call(root.querySelectorAll("[data-for]"))
toArray(root.querySelectorAll("[data-for]"))
.forEach(function (nested) {
// Only the outermost nested templates: deeper ones are set up by
// their own parent when it renders.
@@ -1954,6 +1906,155 @@ export const REACTIVE_RUNTIME = String.raw`
}
}
// Compiled if/each blocks keep their SSR result in the custom
// element and carry an inert, base64-encoded template for later browser
// updates. The first reactive pass only subscribes to dependencies, so
// hydration does not throw away server DOM. Subsequent state changes
// materialize the appropriate branch/rows and hydrate their bindings.
function decodeControlDefinition(value) {
try {
var binary = window.atob(value || "");
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return JSON.parse(new TextDecoder("utf-8").decode(bytes));
} catch (error) {
reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error);
return null;
}
}
function setupControlBlock(block, outerLocals) {
if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return;
block.__wrnexusControl = true;
var inherited = outerLocals || decodeLoopLocals(block);
var rangeEnd = null;
if (block.tagName && block.tagName.toLowerCase() === "template") {
var depth = 0;
for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) {
if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue;
if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++;
if (sibling.hasAttribute("data-wrn-control-end")) {
if (depth === 0) { rangeEnd = sibling; break; }
depth--;
}
}
if (!rangeEnd) return;
}
var ifDefinition = block.hasAttribute("data-wrn-if")
? decodeControlDefinition(block.getAttribute("data-wrn-if"))
: null;
var eachDefinition = block.hasAttribute("data-wrn-each")
? decodeControlDefinition(block.getAttribute("data-wrn-each"))
: null;
/*
* Skip the first reactive pass only when hydrating server DOM.
*
* A block that arrived with the server HTML is already rendered, so
* redrawing on the first pass would discard it. A block created later by
* an outer block's rerender has no server DOM outerLocals is how it
* receives its enclosing loop's scope, and is only ever set on that path.
* Skipping its first pass leaves it permanently empty, because its
* dependencies never change again to trigger a second one.
*/
var firstRun = !outerLocals;
function controlRead(name) {
return hasOwn(inherited, name) ? inherited[name] : readScope(name);
}
function controlEval(expression, locals) {
return evaluateExpression(expression, function (name) {
return locals && hasOwn(locals, name)
? locals[name]
: controlRead(name);
});
}
function clearControlContent() {
if (!rangeEnd) { block.innerHTML = ""; return; }
while (block.nextSibling && block.nextSibling !== rangeEnd) {
block.parentNode.removeChild(block.nextSibling);
}
}
function appendControlContent(markup, locals) {
var template = document.createElement("template");
template.innerHTML = markup || "";
var fragment = template.content;
var elements = toArray(fragment.childNodes).filter(function (node) {
return node.nodeType === 1;
});
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
else block.appendChild(fragment);
elements.forEach(function (node) { hydrateItem(node, locals || inherited); });
elements.forEach(function (node) {
var controls = [];
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
controls.forEach(function (nested) {
if (nested.__wrnexusControl) return;
/*
* Run the new block now rather than waiting for a sweep.
*
* reactive() only registers an effect; effects execute when
* renderAll sweeps the list. A state change runs just the affected
* effects, so a block registered during that rerender is queued and
* never invoked -- it would stay empty for the life of the page.
*/
var runNested = setupControlBlock(nested, locals || inherited);
if (runNested) runNested();
});
});
}
return reactive(function () {
/*
* A block removed from the DOM keeps its effect in the renderers list,
* so a later sweep would run it against a detached node and throw --
* aborting the sweep, leaving every later effect unrendered. Skip it.
*/
if (!block.parentNode) return;
if (ifDefinition) {
var selected = null;
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
var branch = ifDefinition[branchIndex];
if (branch.cond === null || !!controlEval(branch.cond, inherited)) {
selected = branch;
break;
}
}
if (firstRun) { firstRun = false; return; }
clearControlContent();
appendControlContent(selected ? selected.body : "", inherited);
return;
}
if (!eachDefinition) return;
var list = controlEval(eachDefinition.list, inherited);
if (!Array.isArray(list)) list = [];
if (firstRun) { firstRun = false; return; }
clearControlContent();
if (list.length === 0) {
appendControlContent(eachDefinition.empty || "", inherited);
return;
}
for (var itemIndex = 0; itemIndex < list.length; itemIndex++) {
var rowLocals = {};
Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; });
rowLocals[eachDefinition.item] = list[itemIndex];
if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex;
appendControlContent(eachDefinition.body || "", rowLocals);
}
});
}
toArray(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
setupControlBlock(block, null);
});
/*
* Set up one [data-for] template. Extracted from an inline forEach so it
* can recurse: hydrateItem calls it for every loop nested inside a rendered
@@ -1981,7 +2082,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
function loopRead(name) {
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
if (hasOwn(inherited, name)) {
return inherited[name];
}
return readScope(name);
@@ -2154,7 +2255,7 @@ export const REACTIVE_RUNTIME = String.raw`
rawKey = evaluateExpression(
keyExpression,
function (name) {
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
return hasOwn(keyedLocals, name)
? keyedLocals[name]
: loopRead(name);
},
@@ -2227,8 +2328,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
Array.prototype.slice
.call(el.querySelectorAll("[data-for]"))
toArray(el.querySelectorAll("[data-for]"))
.forEach(function (tpl) {
// Only top-level templates here; nested ones are connected by the item
// that contains them, once it has values to give them.
@@ -2324,24 +2424,18 @@ export const REACTIVE_RUNTIME = String.raw`
// Conditional class bindings emitted as:
// data-wrn-class-*='["class-name","expression"]'
var classBindNodes = [el].concat(
Array.prototype.slice.call(el.querySelectorAll("*")),
toArray(el.querySelectorAll("*")),
);
classBindNodes.forEach(function (node) {
if (!owns(node)) return;
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
toArray(node.attributes).forEach(function (marker) {
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
var binding;
var binding = pairBinding(marker.value);
try {
binding = JSON.parse(marker.value);
} catch (e) {
return;
}
if (!binding || binding.length !== 2) return;
if (!binding) return;
var className = binding[0];
var expression = binding[1];
@@ -2370,7 +2464,7 @@ export const REACTIVE_RUNTIME = String.raw`
// [attributeName, originalTemplate], preserving an SSR value while allowing
// state changes to update type, aria-*, class, href, and other attributes.
var bindNodes = [el].concat(
Array.prototype.slice.call(
toArray(
el.querySelectorAll("*"),
),
);
@@ -2378,8 +2472,7 @@ export const REACTIVE_RUNTIME = String.raw`
bindNodes.forEach(function (node) {
if (!owns(node)) return;
Array.prototype.slice
.call(node.attributes)
toArray(node.attributes)
.forEach(function (marker) {
if (
marker.name.indexOf(
@@ -2390,22 +2483,10 @@ export const REACTIVE_RUNTIME = String.raw`
}
node.removeAttribute(marker.name);
var binding;
try {
binding = JSON.parse(
marker.value,
);
} catch (error) {
return;
}
var binding = pairBinding(marker.value);
if (
!binding ||
binding.length !== 2
) {
return;
}
if (!binding) return;
var name = binding[0];
var template = binding[1];
@@ -2497,10 +2578,10 @@ export const REACTIVE_RUNTIME = String.raw`
}
// Event handlers on elements, window, and document.
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
var nodes = [el].concat(toArray(el.querySelectorAll("*")));
nodes.forEach(function (node) {
if (!owns(node)) return;
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-on-") !== 0) return;
var rawName = attr.name.slice("data-on-".length);
@@ -2527,7 +2608,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event;
locals.$event = event;
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
locals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -2576,8 +2657,7 @@ export const REACTIVE_RUNTIME = String.raw`
// function only exists out here. The compiler emits these as data-wrn-out-* so the
// two cases stay distinguishable, and this scope claims every one that
// sits on a component it directly mounts.
Array.prototype.slice
.call(el.querySelectorAll("[data-wrn-events]"))
toArray(el.querySelectorAll("[data-wrn-events]"))
.forEach(function (node) {
var componentRoot = closestScope(node);
if (!componentRoot || componentRoot === el) return;
@@ -2594,7 +2674,7 @@ export const REACTIVE_RUNTIME = String.raw`
node.__wrnexusOutputHandlers ||
(node.__wrnexusOutputHandlers = {});
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
var outName = attr.name.slice("data-wrn-out-".length);
@@ -2632,7 +2712,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event;
locals.$event = event;
locals.payload =
event && Object.prototype.hasOwnProperty.call(event, "detail")
event && hasOwn(event, "detail")
? event.detail
: undefined;
try {
@@ -2654,16 +2734,14 @@ export const REACTIVE_RUNTIME = String.raw`
// Prop expressions belong to the parent that mounted the component. The
// server forwards these markers onto the rendered child root; evaluate
// them here and write changes into the child's prop signals.
Array.prototype.slice
.call(el.querySelectorAll("*"))
toArray(el.querySelectorAll("*"))
.filter(isScopeRoot)
.forEach(function (node) {
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
var binding;
try { binding = JSON.parse(attr.value); } catch (_) { return; }
if (!binding || binding.length !== 2) return;
var binding = pairBinding(attr.value);
if (!binding) return;
var propName = binding[0];
var template = binding[1];
reactive(function () {
@@ -3017,8 +3095,7 @@ export const REACTIVE_RUNTIME = String.raw`
var host = root && root.querySelectorAll ? root : document;
anchoredWriting = true;
try {
Array.prototype.slice
.call(host.querySelectorAll(ANCHORED_SELECTOR))
toArray(host.querySelectorAll(ANCHORED_SELECTOR))
.forEach(clampAnchored);
} finally {
// Released on a timer, not requestAnimationFrame. rAF does not fire in
@@ -3552,8 +3629,7 @@ export const REACTIVE_RUNTIME = String.raw`
// therefore no client-side binding to retain; consume its compiler markers
// separately from component hydration.
if (host === document || host === document.documentElement) {
Array.prototype.slice
.call(document.documentElement.attributes)
toArray(document.documentElement.attributes)
.forEach(function (attribute) {
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
document.documentElement.removeAttribute(attribute.name);
@@ -4171,6 +4247,72 @@ export const REACTIVE_RUNTIME = String.raw`
});
}
/*
* Transport for compiled api blocks.
*
* Only the request and the failure shape live here. A block's response and
* error bodies are page code, so they are emitted into the browser module
* and applied by the caller.
*/
function readCsrfToken() {
var meta = document.querySelector('meta[name="wrnexus-csrf"]');
if (meta) return meta.getAttribute("content") || "";
var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
return match ? decodeURIComponent(match[1]) : "";
}
function wrnexusCallApi(path, method, input) {
var verb = String(method || "GET").toUpperCase();
var values = input || {};
var url = path;
var headers = { accept: "application/json" };
var init = { method: verb, credentials: "same-origin", headers: headers };
if (verb === "GET" || verb === "HEAD") {
var query = [];
Object.keys(values).forEach(function (key) {
var value = values[key];
// An omitted filter must not become "name=undefined".
if (value === undefined || value === null || value === "") return;
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)));
});
if (query.length) url = path + "?" + query.join("&");
} else {
headers["content-type"] = "application/json";
headers["x-csrf-token"] = readCsrfToken();
init.body = JSON.stringify(values);
}
return fetch(url, init).then(function (response) {
// A 2xx with no body (204/205, or a genuinely empty response) is a
// success, not a parse failure -- the failure table only calls for the
// error path on non-2xx, network failure, or an unparseable body.
if (response.ok && (response.status === 204 || response.status === 205)) {
return undefined;
}
return response.json().then(
function (data) {
if (response.ok) return data;
var message =
data && data.error ? String(data.error) : "Request failed with " + response.status;
var failure = new Error(message);
failure.status = response.status;
failure.data = data;
throw failure;
},
function () {
if (response.ok) return undefined;
var failure = new Error("Response was not valid JSON");
failure.status = response.status;
failure.data = undefined;
throw failure;
},
);
});
}
window.__wrnexusCallApi = wrnexusCallApi;
function dispatchComponentEvent(root, name, detail) {
if (!root || !name) return null;
var EventConstructor =
@@ -4195,7 +4337,7 @@ export const REACTIVE_RUNTIME = String.raw`
function emitPinInputEvent(root, name, extra) {
var hidden = root.querySelector("[data-pin-value]");
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
var value = hidden ? hidden.value : "";
var detail = {
component: "PinInput",
@@ -4217,7 +4359,7 @@ export const REACTIVE_RUNTIME = String.raw`
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
function setupPinInputController(root) {
if (!root || root.__wrnexusPinInputController) return;
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
var hidden = root.querySelector("[data-pin-value]");
var clearButton = root.querySelector("[data-pin-clear]");
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
@@ -4651,12 +4793,68 @@ export const REACTIVE_RUNTIME = String.raw`
: null;
}
/*
* Parse a while or for statement into its parts.
*
* Returns null for anything else so the caller falls through to the other
* statement forms. A for header is split on top-level semicolons only, so a
* semicolon inside a call argument or a string does not break it.
*/
function parseLoopStatement(source) {
source = String(source || "").trim();
var kind = null;
if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
kind = "while";
} else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
kind = "for";
} else {
return null;
}
var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
if (source.charAt(index) !== "(") return null;
var headerEnd = findClosingDelimiter(source, index, "(", ")");
if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
var header = source.slice(index + 1, headerEnd);
index = skipStatementWhitespace(source, headerEnd + 1);
if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
var bodyEnd = findClosingDelimiter(source, index, "{", "}");
if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
var body = source.slice(index + 1, bodyEnd);
if (kind === "while") {
return { init: null, condition: header.trim(), step: null, body: body };
}
var parts = splitTopLevel(header, ";");
if (parts.length !== 3) throw new Error("A for header needs three parts");
return {
init: parts[0].trim(),
condition: parts[1].trim(),
step: parts[2].trim(),
body: body,
};
}
function runStatement(
stmt,
evalExpr,
read,
write,
runBlock,
declare,
) {
stmt = String(stmt || "").trim();
@@ -4707,6 +4905,60 @@ export const REACTIVE_RUNTIME = String.raw`
};
}
/*
* A loop body is author-written and runs in the browser, so a mistaken
* condition would freeze the tab. The cap keeps a runaway loop from
* hanging the page; it is far above any list a view renders.
*/
var loop = parseLoopStatement(stmt);
if (loop) {
var guard = 0;
if (loop.init) {
runStatement(loop.init, evalExpr, read, write, runBlock, declare);
}
while (!loop.condition || !!evalExpr(loop.condition)) {
if (++guard > 100000) break;
var outcome = runBlock(loop.body);
if (outcome && outcome.returned) return outcome;
if (loop.step) {
runStatement(loop.step, evalExpr, read, write, runBlock, declare);
}
}
return { returned: false, value: undefined };
}
/*
* A declaration binds a local, then falls through to the assignment
* branch below.
*
* Declaring first is what makes it local: an unknown name reaching
* writeScope becomes a signal and triggers a render sweep, so a var
* inside a shared function called during a render would loop forever.
* Once the name exists in locals, read and write both stay there.
*/
var declaration = stmt.match(
/^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
);
if (declaration) {
stmt = declaration[1].trim();
var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
if (declare) declare(declaredName, undefined);
if (!/=/.test(stmt)) {
return { returned: false, value: undefined };
}
}
var increment = stmt.match(
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
);
@@ -5699,7 +5951,7 @@ export const REACTIVE_RUNTIME = String.raw`
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
if (element.__wrnDynamicMounted) return;
element.__wrnDynamicMounted = true;
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
var cases = toArray(element.children).filter(function (candidate) {
return candidate.hasAttribute("data-component-case");
}).map(function (candidate) {
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
+128
View File
@@ -0,0 +1,128 @@
import { expect, test, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => {
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
});
interface Call {
url: string;
init: RequestInit;
}
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
function harness(response: { status: number; payload: unknown }) {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
const calls: Call[] = [];
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
calls.push({ url, init });
return Promise.resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
json: () => Promise.resolve(response.payload),
});
};
(0, eval)(REACTIVE_RUNTIME);
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
.__wrnexusCallApi;
return { callApi, calls, win };
}
test("GET builds a query string and omits undefined fields", async () => {
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
expect(calls[0]!.init.method).toBe("GET");
expect(calls[0]!.init.body).toBeUndefined();
});
test("POST sends a JSON body", async () => {
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
await callApi("/api/users", "POST", { name: "Ajay" });
expect(calls[0]!.url).toBe("/api/users");
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
"application/json",
);
});
test("a non-GET request carries the CSRF token from the cookie", async () => {
const { callApi, calls, win } = harness({ status: 200, payload: {} });
win.document.cookie = "wrn-csrf=token-123";
await callApi("/api/users", "POST", {});
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
});
test("a 2xx resolves to the parsed payload", async () => {
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
});
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 204,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
(0, eval)(REACTIVE_RUNTIME);
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
.__wrnexusCallApi;
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
});
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
const { callApi } = harness({ status: 200, payload: undefined });
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
});
test("a non-2xx rejects with status, message and data", async () => {
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
const failure = await callApi("/api/users", "GET", {}).catch(
(error: Error & { status?: number; data?: unknown }) => error,
);
expect(failure.status).toBe(400);
expect(failure.message).toContain("Bad filter");
expect(failure.data).toEqual({ error: "Bad filter" });
});
+28
View File
@@ -0,0 +1,28 @@
import { afterAll } from "bun:test";
/**
* Restore globals a suite replaces, once the suite is done.
*
* These suites install a happy-dom window over the real globals and delete
* them before each test so every test starts clean. bun test loads and runs
* one file at a time rather than importing them all up front, so anything left
* deleted is still missing when the next suite runs -- which is how `bun test`
* with no argument came to fail unrelated files with "fetch is not a
* function". Names absent at capture time are deleted again rather than being
* restored as undefined, so a global that never existed does not gain a key.
*/
export function restoreGlobalsAfterAll(names: readonly string[]): void {
const captured = new Map<string, unknown>(
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
);
afterAll(() => {
for (const [name, value] of captured) {
if (value === undefined) {
delete (globalThis as Record<string, unknown>)[name];
} else {
(globalThis as Record<string, unknown>)[name] = value;
}
}
});
}
+67 -10
View File
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
let win: any;
let fetchCalls: { url: string; opts: any }[];
@@ -36,18 +37,22 @@ function install(bodyHtml: string): void {
const flush = () => new Promise((r) => setTimeout(r, 0));
const REPLACED_GLOBALS = [
"window",
"document",
"history",
"location",
"DOMParser",
"CustomEvent",
"Event",
"fetch",
];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => {
const g = globalThis as any;
for (const k of [
"window",
"document",
"history",
"location",
"DOMParser",
"CustomEvent",
"Event",
"fetch",
]) {
for (const k of REPLACED_GLOBALS) {
delete g[k];
}
});
@@ -139,6 +144,58 @@ test("rebinds theme controls after swapping the page", async () => {
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull();
});
test("synchronizes and rebinds i18n data during client navigation", async () => {
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
let boundRoot: unknown;
const translate = () => "translated";
const setLanguage = () => true;
win.__wrnI18n = { lang: "en", messages: { old: "Old" }, t: translate, set: setLanguage };
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
nextHtml =
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
await flush();
expect(win.__wrnI18n.lang).toBe("mr");
expect(win.__wrnI18n.messages.home.title).toBe("नवीन");
expect(win.__wrnI18n.t).toBe(translate);
expect(win.__wrnI18n.set).toBe(setLanguage);
expect(boundRoot).toBe(win.document.getElementById("app"));
});
test("preserves same-language translations when an incoming navigation catalog is partial", async () => {
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
win.__wrnI18n = {
lang: "en",
messages: { navigation: { home: "Home" }, footer: { contact: "Contact" } },
fallbackMessages: {},
};
win.__wrnLang = {
bind: (root: ParentNode) => {
root.querySelectorAll("[data-t]").forEach((node) => {
const parts = String(node.getAttribute("data-t") || "").split(".");
let value: any = win.__wrnI18n.messages;
for (const part of parts) value = value?.[part];
node.textContent = typeof value === "string" ? value : node.getAttribute("data-t");
});
},
};
nextHtml =
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
await flush();
expect(win.__wrnI18n.messages.navigation.home).toBe("Home");
expect(win.__wrnI18n.messages.footer.contact).toBe("Contact");
expect(win.document.querySelector("[data-t]")?.textContent).toBe("Home");
});
test("unmounts and remounts package runtimes during client navigation", async () => {
install(
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
+204 -5
View File
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
@@ -34,12 +35,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
return win as unknown as Window;
}
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
delete (globalThis as Record<string, unknown>).location;
delete (globalThis as Record<string, unknown>).fetch;
delete (globalThis as Record<string, unknown>).MutationObserver;
for (const name of REPLACED_GLOBALS) {
delete (globalThis as Record<string, unknown>)[name];
}
});
test("split runtime hydrates a controller only from the controller asset", () => {
@@ -117,6 +120,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
expect(btn.textContent).toBe("2");
});
test("compiled if blocks switch branches after hydration", () => {
const definition = Buffer.from(
JSON.stringify([
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
{ cond: null, body: '<p class="closed">Closed</p>' },
]),
).toString("base64");
const win = mount(
`<div data-scope="open: false, count: 2">` +
`<button data-on-click="open = !open">toggle</button>` +
`<button data-on-click="count++">increment</button>` +
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector(".closed")).toBeNull();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
});
test("compiled each blocks rerender rows and their empty branch", () => {
const definition = Buffer.from(
JSON.stringify({
list: "items",
item: "item",
index: "index",
body: '<p class="row">{index}:{item}</p>',
empty: '<p class="empty">Empty</p>',
}),
).toString("base64");
const win = mount(
`<div data-scope="items: ['a']">` +
`<button data-on-click="items = ['b', 'c']">more</button>` +
`<button data-on-click="items = []">clear</button>` +
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelectorAll("button")[0]!.click();
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
["0:b", "1:c"],
);
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".row")).toBeNull();
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
});
test("component functions support formatted multiline assignments and ternaries", () => {
const behavior = Buffer.from(
JSON.stringify({
@@ -1582,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
);
expect(seen).toEqual([60]);
});
test("control blocks created by a client rerender render their own content", () => {
// A nested block that arrives with the server HTML is hydrated: its first
// reactive pass must NOT redraw, or it would throw away server DOM. A nested
// block created later by an outer rerender has no server DOM, so skipping its
// first pass leaves it permanently empty — its dependencies never change
// again to trigger a second one.
const inner = Buffer.from(
JSON.stringify([
{ cond: "g.rows.length > 0", body: '<p class="has-rows">HAS</p>' },
{ cond: null, body: '<p class="no-rows">NONE</p>' },
]),
).toString("base64");
const outer = Buffer.from(
JSON.stringify({
list: "groups",
item: "g",
body:
`<section class="group"><span data-text="g.name">{g.name}</span>` +
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
`</section>`,
empty: "",
}),
).toString("base64");
const win = mount(
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
`<template data-wrn-each="${outer}"></template>` +
`<section class="group"><span data-text="g.name">g1</span>` +
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
`<template data-wrn-control-end></template></section>` +
`<template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelector("button")!.click();
// The outer each rerendered: the new group's heading is present.
expect(win.document.querySelector(".group span")?.textContent).toBe("g2");
// The nested if inside that new row must have rendered its else branch.
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
expect(win.document.querySelector(".has-rows")).toBeNull();
});
test("a for loop with a declaration initialiser runs in a handler", () => {
const win = mount(
`<div data-scope="total: 0">` +
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
`<span data-text="total">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("6");
});
test("a while loop runs in a handler", () => {
const win = mount(
`<div data-scope="n: 1">` +
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
`<span data-text="n">1</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("16");
});
test("a declaration stays local instead of becoming reactive state", () => {
// An unknown name reaching writeScope becomes a signal and triggers a render
// sweep. A var inside a function called during a render would then loop
// forever, so declarations must bind locally.
const win = mount(
`<div data-scope="out: 0">` +
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
`<span class="out" data-text="out">0</span>` +
`<span class="leak" data-text="step"></span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector(".out")?.textContent).toBe("6");
expect(win.document.querySelector(".leak")?.textContent).toBe("");
});
test("a control block removed from the DOM does not abort later renders", () => {
// Its effect stays in the renderers list. Running it against a detached node
// throws, which would abort the sweep and leave every later effect stale.
const definition = Buffer.from(
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
).toString("base64");
const win = mount(
`<div data-scope="n: 0">` +
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
`<button data-on-click="n = n + 1">go</button>` +
`<span data-text="n">0</span>` +
`</div>`,
);
const block = win.document.querySelector("[data-wrn-if]")!;
block.parentNode!.removeChild(block);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("1");
});
test("a declaration statement assigns into scope", () => {
const win = mount(
`<div data-scope="out: 0">` +
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
`<span data-text="out">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("6");
});
test("an unbounded loop stops instead of hanging the page", () => {
// Handler source is author-controlled and runs in the browser. Without a cap
// a mistaken condition freezes the tab with no way back.
const win = mount(
`<div data-scope="n: 0">` +
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
`<span data-text="n">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
const value = Number(win.document.querySelector("span")?.textContent);
expect(value).toBeGreaterThan(0);
expect(Number.isFinite(value)).toBe(true);
});
test("a class binding inside data-for follows state the row never mentions", () => {
// The row's own array is untouched, so nothing rebuilds the list. The
// binding has to be reactive in its own right to keep up.
const binding = JSON.stringify(["is-active", "selected === row.id"]);
const win = mount(
`<div data-scope="rows: [{&quot;id&quot;:1},{&quot;id&quot;:2}], selected: 1">` +
`<button data-on-click="selected = 2">pick</button>` +
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
`</div>`,
);
const items = () => Array.from(win.document.querySelectorAll("li"));
expect(items()[0]?.classList.contains("is-active")).toBe(true);
expect(items()[1]?.classList.contains("is-active")).toBe(false);
win.document.querySelector("button")!.click();
expect(items()[0]?.classList.contains("is-active")).toBe(false);
expect(items()[1]?.classList.contains("is-active")).toBe(true);
});
+6 -1
View File
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
let sockets: FakeWS[];
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
return win as unknown as Window;
}
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => {
for (const k of ["window", "document", "location", "WebSocket"]) {
for (const k of REPLACED_GLOBALS) {
delete (globalThis as Record<string, unknown>)[k];
}
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.12",
"version": "0.8.16",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -26,6 +26,6 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
}
}
+18 -6
View File
@@ -14,7 +14,18 @@ import type { Db } from "./driver.ts";
const DEFAULT = "default";
type DbFactory = () => Db;
type RegistryEntry = { db?: Db; factory?: DbFactory };
const registry = new Map<string, RegistryEntry>();
const REGISTRY_KEY = Symbol.for("@wrnexus/db:registry:v1");
type RegistryGlobal = typeof globalThis & { [REGISTRY_KEY]?: Map<string, RegistryEntry> };
// Production bundlers can include @wrnexus/db more than once when an app and
// the server runtime resolve compatible but distinct package installations.
// A module-local Map splits configuration from consumers in that case. Store
// the registry on globalThis under a stable symbol so every bundled copy in
// the process observes the same default and named connections.
function databaseRegistry(): Map<string, RegistryEntry> {
const scope = globalThis as RegistryGlobal;
return (scope[REGISTRY_KEY] ??= new Map<string, RegistryEntry>());
}
/** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db;
@@ -23,7 +34,7 @@ export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a;
registry.set(name, { db });
databaseRegistry().set(name, { db });
return db;
}
@@ -37,12 +48,12 @@ export function registerDb(name: string, db: Db): Db {
* `getDb(name)` call creates and caches the connection.
*/
export function registerLazyDb(name: string, factory: DbFactory): void {
registry.set(name, { factory });
databaseRegistry().set(name, { factory });
}
/** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db {
const entry = registry.get(name);
const entry = databaseRegistry().get(name);
if (!entry) {
throw new Error(
name === DEFAULT
@@ -60,16 +71,17 @@ export function getDb(name = DEFAULT): Db {
/** Whether the default (or a named) database has been configured. */
export function hasDb(name = DEFAULT): boolean {
return registry.has(name);
return databaseRegistry().has(name);
}
/** Names of all configured databases (the default appears as "default"). */
export function databaseNames(): string[] {
return [...registry.keys()];
return [...databaseRegistry().keys()];
}
/** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> {
const registry = databaseRegistry();
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
registry.clear();
const results = await Promise.allSettled(databases.map((db) => db.close()));
+9 -4
View File
@@ -226,11 +226,16 @@ export function generateQueriesFile(
);
}
const imports = [
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
];
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
}
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
// The dialect is stamped into the header because it changes the emitted SQL:
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
// under a different profile therefore rewrites this committed file, and
// without the stamp the diff looks like unexplained churn.
return (
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
);
}
+41 -1
View File
@@ -86,6 +86,41 @@ function hasExecutableSql(sql: string): boolean {
return false;
}
function additiveColumnTarget(sql: string): { table: string; column: string } | undefined {
const executable = sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\r\n]*/g, " ")
.trim();
const match =
/^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
executable,
);
return match ? { table: match[1]!, column: match[2]! } : undefined;
}
async function additiveColumnAlreadyExists(db: Db, sql: string): Promise<boolean> {
const target = additiveColumnTarget(sql);
if (!target) return false;
if (db.driver.dialect === "sqlite") {
const columns = await db.all<{ name: string }>(`PRAGMA table_info(${target.table})`);
return columns.some(({ name }) => name.toLowerCase() === target.column.toLowerCase());
}
if (db.driver.dialect === "postgres") {
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
/** Load and parse all migration files in a directory, sorted by filename. */
export function loadMigrations(dir: string): Migration[] {
if (!existsSync(dir)) return [];
@@ -171,7 +206,12 @@ export async function applyMigrations(
for (const migration of pending.filter(({ name }) => !current.has(name))) {
throwIfAborted(options.signal);
await db.tx(async (tx) => {
if (hasExecutableSql(migration.up)) await tx.exec(migration.up);
if (
hasExecutableSql(migration.up) &&
!(await additiveColumnAlreadyExists(tx, migration.up))
) {
await tx.exec(migration.up);
}
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
});
done.push(migration.name);
+18
View File
@@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async (
await db.close();
});
test("add-column migrations recover when the column exists but the migration record does not", async () => {
const db = createDb(sqlite());
await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)");
const migrations = [
{
name: "0002_otp_purpose",
up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';",
down: "ALTER TABLE otp_challenges DROP COLUMN purpose;",
},
];
expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]);
expect(await appliedMigrations(db)).toContain("0002_otp_purpose");
const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)");
expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1);
await db.close();
});
test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
const db = createDb(sqlite());
const migrations = [
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
test("the generated header records the dialect it was built for", () => {
// The same command emits different SQL per dialect, so a build under another
// profile rewrites the committed file. The stamp makes that visible in the
// diff instead of looking like unexplained churn.
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
});
test("placeholder style follows the dialect", () => {
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
});
test("generation is deterministic for a fixed dialect", () => {
const first = generateQueriesFile(queries, [], "postgres");
const second = generateQueriesFile(queries, [], "postgres");
expect(first).toBe(second);
});
+13
View File
@@ -92,3 +92,16 @@ test("registry closes every database and clears itself when one close fails", as
expect(secondClosed).toBe(true);
expect(databaseNames()).toEqual([]);
});
test("separately evaluated package copies share the process-wide registry", async () => {
await closeDatabases();
const secondCopy = await import(`../src/client.ts?copy=${crypto.randomUUID()}`);
const main = createDb(sqlite(":memory:"));
setDb(main);
expect(secondCopy.hasDb()).toBe(true);
expect(secondCopy.getDb()).toBe(main);
await secondCopy.closeDatabases();
expect(hasDb()).toBe(false);
});

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