Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f075df42c | ||
|
|
281615a4b0 | ||
|
|
3ae5d7cf97 | ||
|
|
b5029889a5 | ||
|
|
e9db4ca24d | ||
|
|
5318320c70 | ||
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 | ||
|
|
2cbc3e43e1 | ||
|
|
55fed2177a | ||
|
|
8c609edd32 | ||
|
|
e898929193 | ||
|
|
18a1c40118 | ||
|
|
a20f143acb | ||
|
|
ac248f2bb0 | ||
|
|
0904a4efaa | ||
|
|
5b11b937bb | ||
|
|
f199385204 | ||
|
|
c0c2fa4595 | ||
|
|
cfdcdd00ad | ||
|
|
03d5cb6aa6 | ||
|
|
701acd828c | ||
|
|
b28b79c370 | ||
|
|
fdd0c9f847 | ||
|
|
7ad336b4dd | ||
|
|
d70e89230b | ||
|
|
7e6d8c3bc8 | ||
|
|
92c0920c9b | ||
|
|
a8d8ac386f | ||
|
|
2c8841cc5f | ||
|
|
b344d2a70a | ||
|
|
e83f0366ef | ||
|
|
d9e8f5be82 | ||
|
|
bd0c1317ff | ||
|
|
97801287f9 | ||
|
|
d77638131b | ||
|
|
609224591c | ||
|
|
6074d19c43 | ||
|
|
7301849a7f | ||
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d | ||
|
|
e819c5739e | ||
|
|
ac164789bd | ||
|
|
88ec3a5cb6 | ||
|
|
28085b5a43 | ||
|
|
5d7bd81601 | ||
|
|
5e114d867f | ||
|
|
10421465df | ||
|
|
52cce2c628 | ||
|
|
afa2a8c093 | ||
|
|
4dd7681bf7 | ||
|
|
843db2815f | ||
|
|
17aa3b98eb | ||
|
|
442a3106ed | ||
|
|
3115277e9d | ||
|
|
02fdfa3aee | ||
|
|
5f66c8129c | ||
|
|
01a3b3b4e9 | ||
|
|
a184f1a3be | ||
|
|
06df9d66ae | ||
|
|
d269772a79 | ||
|
|
df2ee036eb | ||
|
|
b405025f37 | ||
|
|
cd07f0f8e2 | ||
|
|
ce67d0d1d2 | ||
|
|
468f63f378 | ||
|
|
a6570d7f68 | ||
|
|
4ef2ef14ff | ||
|
|
d78707be9f | ||
|
|
1d16ef1e82 | ||
|
|
12a1014db2 | ||
|
|
52b3e6d378 | ||
|
|
f88dd47408 | ||
|
|
f0447fddb0 |
@@ -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,3 +1,3 @@
|
||||
@wrnexus:registry=https://registry.npmjs.org/
|
||||
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||
audit=true
|
||||
fund=false
|
||||
|
||||
@@ -13,6 +13,7 @@ bun.lockb
|
||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
**/*.generated.api-checks.ts
|
||||
|
||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||
editors/vscode/src/compiler.cjs
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
|
||||
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
|
||||
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
|
||||
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
|
||||
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
|
||||
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
|
||||
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
|
||||
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
|
||||
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
|
||||
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
|
||||
required boolean field given `false` still errors, as before (checkbox-required semantics
|
||||
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
|
||||
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
|
||||
to start failing.
|
||||
|
||||
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
|
||||
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
|
||||
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
|
||||
when no input is passed explicitly. Previously such endpoints silently validated
|
||||
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
|
||||
what was sent. **Behavior change for downstream apps:** a request that previously passed
|
||||
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
|
||||
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
|
||||
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
|
||||
priority over reading the request.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Added the framework request context to `.wrn` language-server type environments.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||
stale asynchronous edits, and duplicate client-side suggestions.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||
@@ -16,8 +25,8 @@
|
||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||
diagnostics in packaged extension environments.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||
consistently in the repository and extension development environment.
|
||||
|
||||
|
||||
@@ -134,6 +134,11 @@
|
||||
"maximum": 240,
|
||||
"scope": "resource",
|
||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||
},
|
||||
"wrnexus.html.autoClosingTags": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -141,6 +146,9 @@
|
||||
"files.associations": {
|
||||
"*.wrn": "wrn"
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"wrn": "html"
|
||||
},
|
||||
"[wrn]": {
|
||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||
"editor.formatOnSave": false,
|
||||
@@ -276,13 +284,14 @@
|
||||
"check": "bun run build && bun run test && bun run validate",
|
||||
"vscode:prepublish": "bun run check",
|
||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
||||
"publish": "vsce publish --no-dependencies",
|
||||
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"vscode-languageclient": "^10.1.0"
|
||||
"vscode-languageclient": "^10.1.0",
|
||||
"vscode-html-languageservice": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
|
||||
const vscode = require("vscode");
|
||||
|
||||
/**
|
||||
* Auto-close tags as they are typed.
|
||||
*
|
||||
* LSP has no request for this, so the client watches document changes and asks
|
||||
* the server whether the tag should close. The server owns the decision because
|
||||
* void elements and already-closed tags must not be closed.
|
||||
*/
|
||||
function registerAutoCloseTags(context, client) {
|
||||
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||
if (event.document.languageId !== "wrn") return;
|
||||
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||
|
||||
const changes = event.contentChanges;
|
||||
if (!changes.length) return;
|
||||
|
||||
const typed = changes[0].text;
|
||||
if (typed !== ">" && typed !== "/") return;
|
||||
// Every cursor must have typed the same trigger. A replaced selection
|
||||
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== event.document) return;
|
||||
|
||||
/*
|
||||
* Positions come from the editor's selections, not from the changes.
|
||||
*
|
||||
* A change's `range` is in coordinates from before the whole event, so with
|
||||
* several cursors on one line every range after the first is short by the
|
||||
* insertions preceding it. The selections have already been adjusted for
|
||||
* the edit, so they are where the carets actually are.
|
||||
*/
|
||||
const positions = editor.selections.map((selection) => selection.active);
|
||||
if (positions.length !== changes.length) return;
|
||||
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||
|
||||
const documentVersion = event.document.version;
|
||||
const snippets = await Promise.all(
|
||||
positions.map((position) =>
|
||||
client.sendRequest("wrn/tagComplete", {
|
||||
textDocument: { uri: event.document.uri.toString() },
|
||||
position: { line: position.line, character: position.character },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||
/*
|
||||
* One insertSnippet call carries one snippet, and it is the only form that
|
||||
* keeps every caret: inserting sequentially would collapse the selection to
|
||||
* the first snippet and invalidate the remaining positions. Cursors that
|
||||
* want different closing tags are therefore declined rather than
|
||||
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||
* this is for, produces one snippet for all of them.
|
||||
*/
|
||||
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||
|
||||
// The user may have kept typing during the round-trip; re-validate everything the
|
||||
// insertion depends on before touching the document, since a stale offset would
|
||||
// silently corrupt it.
|
||||
if (vscode.window.activeTextEditor !== editor) return;
|
||||
if (editor.document !== event.document) return;
|
||||
if (editor.document.version !== documentVersion) return;
|
||||
if (editor.selections.length !== positions.length) return;
|
||||
if (
|
||||
!editor.selections.every(
|
||||
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||
});
|
||||
|
||||
context.subscriptions.push(listener);
|
||||
}
|
||||
|
||||
module.exports = { registerAutoCloseTags };
|
||||
+806
-53
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+25064
-1696
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
translate(lineDelta, characterDelta) {
|
||||
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||
}
|
||||
isEqual(other) {
|
||||
return this.line === other.line && this.character === other.character;
|
||||
}
|
||||
}
|
||||
|
||||
class Selection {
|
||||
constructor(active) {
|
||||
this.active = active;
|
||||
this.anchor = active;
|
||||
this.isEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
class SnippetString {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
let changeListener = null;
|
||||
const host = {
|
||||
Position,
|
||||
Selection,
|
||||
SnippetString,
|
||||
workspace: {
|
||||
onDidChangeTextDocument(listener) {
|
||||
changeListener = listener;
|
||||
return { dispose() {} };
|
||||
},
|
||||
getConfiguration() {
|
||||
return { get: (_key, fallback) => fallback };
|
||||
},
|
||||
},
|
||||
window: { activeTextEditor: null },
|
||||
};
|
||||
|
||||
const restoreHost = installVsCodeHost(host);
|
||||
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||
restoreHost();
|
||||
|
||||
/**
|
||||
* Drive the handler the way VS Code does: the document has already been
|
||||
* updated and the carets moved by the time the change event fires.
|
||||
*/
|
||||
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||
const inserted = [];
|
||||
const asked = [];
|
||||
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||
const editor = {
|
||||
document,
|
||||
selections: carets.map((caret) => new Selection(caret)),
|
||||
insertSnippet(snippet, positions) {
|
||||
inserted.push({ value: snippet.value, positions });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
};
|
||||
editor.selection = editor.selections[0];
|
||||
host.window.activeTextEditor = editor;
|
||||
|
||||
const client = {
|
||||
sendRequest(_method, params) {
|
||||
asked.push(params.position);
|
||||
return Promise.resolve(snippetFor(params.position));
|
||||
},
|
||||
};
|
||||
|
||||
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||
|
||||
return {
|
||||
inserted,
|
||||
asked,
|
||||
fire: () =>
|
||||
changeListener({
|
||||
document,
|
||||
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||
contentChanges: carets.map(() => ({
|
||||
text: typed,
|
||||
rangeLength: 0,
|
||||
range: { start: new Position(0, 0) },
|
||||
})),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("closes the tag at a single caret", async () => {
|
||||
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 1);
|
||||
assert.equal(run.inserted[0].value, "$0</div>");
|
||||
assert.deepEqual(
|
||||
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||
[[1, 8]],
|
||||
);
|
||||
});
|
||||
|
||||
test("closes the tag at every caret in one insertion", async () => {
|
||||
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||
// sequentially would collapse the selection to the first snippet.
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||
snippetFor: () => "$0</div>",
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.asked.length, 3);
|
||||
assert.equal(run.inserted.length, 1);
|
||||
assert.deepEqual(
|
||||
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||
[
|
||||
[1, 8],
|
||||
[2, 8],
|
||||
[3, 8],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||
// Every contentChange above reports (0, 0). Using those would query and
|
||||
// insert at the wrong offsets once more than one caret is on a line.
|
||||
const run = scenario({
|
||||
carets: [new Position(4, 12), new Position(9, 3)],
|
||||
snippetFor: () => "$0</p>",
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.deepEqual(
|
||||
run.asked.map((p) => [p.line, p.character]),
|
||||
[
|
||||
[4, 12],
|
||||
[9, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("declines when the carets want different closing tags", async () => {
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8)],
|
||||
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
|
||||
test("declines when any caret has no tag to close", async () => {
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8)],
|
||||
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
|
||||
test("declines a replaced selection", async () => {
|
||||
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||
await changeListener({
|
||||
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||
});
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
const restoreHost = installVsCodeHost({
|
||||
Position: class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
},
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
CompletionItem: class CompletionItem {
|
||||
constructor(label, kind) {
|
||||
this.label = label;
|
||||
this.kind = kind;
|
||||
}
|
||||
},
|
||||
CompletionItemKind: {
|
||||
Event: 23,
|
||||
Property: 10,
|
||||
Function: 12,
|
||||
Keyword: 14,
|
||||
Variable: 13,
|
||||
},
|
||||
SnippetString: class SnippetString {
|
||||
constructor(text) {
|
||||
this.value = text;
|
||||
}
|
||||
},
|
||||
MarkdownString: class MarkdownString {
|
||||
constructor(text) {
|
||||
this.value = text;
|
||||
}
|
||||
},
|
||||
});
|
||||
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||
restoreHost();
|
||||
|
||||
const PAGE = `page Home {
|
||||
view {
|
||||
<div>hello</div>
|
||||
}
|
||||
functions {
|
||||
function go() {}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
test("a markup offset is inside a view block", () => {
|
||||
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||
});
|
||||
|
||||
test("a functions-block offset is not inside a view block", () => {
|
||||
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||
});
|
||||
|
||||
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||
const document = {
|
||||
getText() {
|
||||
return PAGE;
|
||||
},
|
||||
offsetAt() {
|
||||
return PAGE.indexOf("<div");
|
||||
},
|
||||
lineAt() {
|
||||
return { text: "<div>hello</div>" };
|
||||
},
|
||||
fileName: "test.wrn",
|
||||
};
|
||||
const position = { line: 2, character: 4 };
|
||||
|
||||
const result = provideCompletionItems(document, position);
|
||||
assert.equal(Array.isArray(result), true);
|
||||
assert.equal(result.length, 0);
|
||||
});
|
||||
|
||||
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||
const document = {
|
||||
getText() {
|
||||
return PAGE;
|
||||
},
|
||||
offsetAt() {
|
||||
return PAGE.indexOf("function go");
|
||||
},
|
||||
lineAt() {
|
||||
return { text: " function go() {}" };
|
||||
},
|
||||
fileName: "test.wrn",
|
||||
};
|
||||
const position = { line: 5, character: 4 };
|
||||
|
||||
const result = provideCompletionItems(document, position);
|
||||
assert.equal(Array.isArray(result), true);
|
||||
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||
});
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const Module = require("node:module");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
// These extraction helpers are pure, but their module also registers VS Code
|
||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return {};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({});
|
||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
test("extracts dynamic route params from filename", () => {
|
||||
const document = {
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const Module = require("node:module");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") {
|
||||
return {
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
});
|
||||
const {
|
||||
findTopLevelDeclaration,
|
||||
maskLeadingTrivia,
|
||||
@@ -34,7 +28,7 @@ const {
|
||||
validateLayoutUsage,
|
||||
validateRootMembers,
|
||||
} = require("../src/diagnostics");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
function mockDocument() {
|
||||
return {
|
||||
|
||||
@@ -113,6 +113,21 @@ try {
|
||||
readFileSync(join(root, rel), "utf8");
|
||||
ok(`Marketplace document exists: ${rel}`);
|
||||
}
|
||||
|
||||
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||
emmetLanguages?.wrn === "html"
|
||||
? ok("Emmet is mapped for wrn documents")
|
||||
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||
|
||||
const autoClose =
|
||||
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||
autoClose?.type === "boolean" && autoClose?.default === true
|
||||
? ok("auto-closing tags setting is contributed")
|
||||
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||
|
||||
manifest.dependencies?.["vscode-html-languageservice"]
|
||||
? ok("HTML language service ships as a runtime dependency")
|
||||
: bad("HTML language service ships as a runtime dependency");
|
||||
} catch (e) {
|
||||
bad("Marketplace metadata", e.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||
*
|
||||
* These files run under `node --test` (see the package's test script), where
|
||||
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||
* root also picks them up by filename, and Bun resolves `require` through its
|
||||
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||
* module covers that case, leaving one shim that works under both runners.
|
||||
*
|
||||
* Returns a function restoring the original loader.
|
||||
*/
|
||||
function installVsCodeHost(stub) {
|
||||
const Module = require("node:module");
|
||||
|
||||
if (typeof Bun !== "undefined") {
|
||||
require("bun").plugin({
|
||||
name: "vscode-host-stub",
|
||||
setup(build) {
|
||||
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return stub;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Module._load = originalLoad;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installVsCodeHost };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineEndpoint } from "@wrnexus/core";
|
||||
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
|
||||
|
||||
interface DirectoryUser {
|
||||
name: string;
|
||||
designation: string;
|
||||
}
|
||||
|
||||
const ALL: DirectoryUser[] = [
|
||||
{ name: "Ajay", designation: "UI" },
|
||||
{ name: "Asha", designation: "Backend" },
|
||||
{ name: "Chen", designation: "UI" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Schema-typed handler: input resolves to `{ name?: string }` from
|
||||
* SearchDirectorySchema, so the block's request shape is checked for real
|
||||
* (not against `unknown`, which the untyped-handler shape resolved to).
|
||||
*/
|
||||
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
|
||||
input: SearchDirectorySchema,
|
||||
description: "Case-insensitive substring search over the demo directory by name.",
|
||||
handler(input) {
|
||||
const needle = String(input.name ?? "").toLowerCase();
|
||||
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
||||
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||
import type { Db, ExecResult } from "@wrnexus/db";
|
||||
import { 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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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, "/"),
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;" : ""}
|
||||
|
||||
@@ -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 = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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("<");
|
||||
expect(html).toContain(""");
|
||||
});
|
||||
|
||||
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(""start":3");
|
||||
expect(out).toContain(""live":true");
|
||||
expect(out).toContain(""title":"Revenue"");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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") || ""));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, test, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||
|
||||
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
|
||||
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
|
||||
});
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
|
||||
function harness(response: { status: number; payload: unknown }) {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||
const calls: Call[] = [];
|
||||
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return Promise.resolve({
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
json: () => Promise.resolve(response.payload),
|
||||
});
|
||||
};
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||
.__wrnexusCallApi;
|
||||
return { callApi, calls, win };
|
||||
}
|
||||
|
||||
test("GET builds a query string and omits undefined fields", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
|
||||
|
||||
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
|
||||
expect(calls[0]!.init.method).toBe("GET");
|
||||
expect(calls[0]!.init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("POST sends a JSON body", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
|
||||
|
||||
await callApi("/api/users", "POST", { name: "Ajay" });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users");
|
||||
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||
win.document.cookie = "wrn-csrf=token-123";
|
||||
|
||||
await callApi("/api/users", "POST", {});
|
||||
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
|
||||
});
|
||||
|
||||
test("a 2xx resolves to the parsed payload", async () => {
|
||||
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
|
||||
|
||||
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||
});
|
||||
|
||||
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||
});
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||
.__wrnexusCallApi;
|
||||
|
||||
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
|
||||
const { callApi } = harness({ status: 200, payload: undefined });
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||
});
|
||||
|
||||
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("a non-2xx rejects with status, message and data", async () => {
|
||||
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||
|
||||
const failure = await callApi("/api/users", "GET", {}).catch(
|
||||
(error: Error & { status?: number; data?: unknown }) => error,
|
||||
);
|
||||
|
||||
expect(failure.status).toBe(400);
|
||||
expect(failure.message).toContain("Bad filter");
|
||||
expect(failure.data).toEqual({ error: "Bad filter" });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterAll } from "bun:test";
|
||||
|
||||
/**
|
||||
* Restore globals a suite replaces, once the suite is done.
|
||||
*
|
||||
* These suites install a happy-dom window over the real globals and delete
|
||||
* them before each test so every test starts clean. bun test loads and runs
|
||||
* one file at a time rather than importing them all up front, so anything left
|
||||
* deleted is still missing when the next suite runs -- which is how `bun test`
|
||||
* with no argument came to fail unrelated files with "fetch is not a
|
||||
* function". Names absent at capture time are deleted again rather than being
|
||||
* restored as undefined, so a global that never existed does not gain a key.
|
||||
*/
|
||||
export function restoreGlobalsAfterAll(names: readonly string[]): void {
|
||||
const captured = new Map<string, unknown>(
|
||||
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
for (const [name, value] of captured) {
|
||||
if (value === undefined) {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>)[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { 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>`,
|
||||
|
||||
@@ -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: [{"id":1},{"id":2}], selected: 1">` +
|
||||
`<button data-on-click="selected = 2">pick</button>` +
|
||||
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
|
||||
`</div>`,
|
||||
);
|
||||
const items = () => Array.from(win.document.querySelectorAll("li"));
|
||||
expect(items()[0]?.classList.contains("is-active")).toBe(true);
|
||||
expect(items()[1]?.classList.contains("is-active")).toBe(false);
|
||||
|
||||
win.document.querySelector("button")!.click();
|
||||
|
||||
expect(items()[0]?.classList.contains("is-active")).toBe(false);
|
||||
expect(items()[1]?.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { 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];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user