Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,3 +1,3 @@
|
|||||||
@wrnexus:registry=https://registry.npmjs.org/
|
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||||
audit=true
|
audit=true
|
||||||
fund=false
|
fund=false
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ Supported view features include:
|
|||||||
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
||||||
- comments and scoped styles
|
- comments and scoped styles
|
||||||
|
|
||||||
|
`{#if}` and `{#each}` are rendered on the server for the initial response and
|
||||||
|
remain reactive after hydration. Browser state changes switch conditional
|
||||||
|
branches and rerender loop rows, including the `{:empty}` branch.
|
||||||
|
|
||||||
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
||||||
boundaries.
|
boundaries.
|
||||||
|
|
||||||
|
|||||||
@@ -973,6 +973,10 @@
|
|||||||
"EffectBlock",
|
"EffectBlock",
|
||||||
"EventDecl",
|
"EventDecl",
|
||||||
"FormatWrnOptions",
|
"FormatWrnOptions",
|
||||||
|
"IslandBuildResult",
|
||||||
|
"IslandDiagnostic",
|
||||||
|
"IslandInput",
|
||||||
|
"IslandStrategy",
|
||||||
"LexError",
|
"LexError",
|
||||||
"Lexer",
|
"Lexer",
|
||||||
"LoadBlock",
|
"LoadBlock",
|
||||||
@@ -999,7 +1003,9 @@
|
|||||||
"analyzeOptimizations",
|
"analyzeOptimizations",
|
||||||
"analyzeRuntimeImports",
|
"analyzeRuntimeImports",
|
||||||
"analyzeRuntimeRequirements",
|
"analyzeRuntimeRequirements",
|
||||||
|
"assertReactAvailable",
|
||||||
"assertValidAst",
|
"assertValidAst",
|
||||||
|
"buildIslands",
|
||||||
"compilationKey",
|
"compilationKey",
|
||||||
"compile",
|
"compile",
|
||||||
"compileNativeWrnFile",
|
"compileNativeWrnFile",
|
||||||
@@ -1015,19 +1021,27 @@
|
|||||||
"generate",
|
"generate",
|
||||||
"generateBrowserModule",
|
"generateBrowserModule",
|
||||||
"generateDeclarations",
|
"generateDeclarations",
|
||||||
|
"generateIslandEntry",
|
||||||
"generateNative",
|
"generateNative",
|
||||||
"generateServerFunctionsModule",
|
"generateServerFunctionsModule",
|
||||||
"generateStoreBrowserModule",
|
"generateStoreBrowserModule",
|
||||||
"generateStoreModule",
|
"generateStoreModule",
|
||||||
"generateTargets",
|
"generateTargets",
|
||||||
"inferredRuntimeType",
|
"inferredRuntimeType",
|
||||||
|
"islandNamesFrom",
|
||||||
|
"islandPropValue",
|
||||||
"optimizeAst",
|
"optimizeAst",
|
||||||
"parse",
|
"parse",
|
||||||
|
"parseIslandStrategy",
|
||||||
|
"renderIslandMarker",
|
||||||
"resolveWrnImport",
|
"resolveWrnImport",
|
||||||
"resolveWrnImports",
|
"resolveWrnImports",
|
||||||
|
"routeNeedsIslands",
|
||||||
"rpcManifest",
|
"rpcManifest",
|
||||||
"runtimeCapabilities",
|
"runtimeCapabilities",
|
||||||
"runtimeTypeOf"
|
"runtimeTypeOf",
|
||||||
|
"serializeIslandProps",
|
||||||
|
"stripBrowserTypes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/content": {
|
"@wrnexus/content": {
|
||||||
@@ -1661,6 +1675,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1697,6 +1712,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
@@ -2259,6 +2275,42 @@
|
|||||||
"subjectQueue"
|
"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": {
|
"@wrnexus/reactive": {
|
||||||
".": [
|
".": [
|
||||||
"AnimationTimeline",
|
"AnimationTimeline",
|
||||||
|
|||||||
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.
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
|
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
|
||||||
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
|
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
|
||||||
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
|
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
|
||||||
"packages/ui/components/Card.wrn": "255912df0668ebcf45b6cc3ffe99a4a3e0bf87376a5d199e3adcdf3610ea79e3",
|
"packages/ui/components/Card.wrn": "3cc2ff238bda279c169026a462236144ad11ec0046deef00ab00e001a07e8a4c",
|
||||||
"packages/ui/components/Carousel.wrn": "7ab122bc0ce772346c665b43d62e73c9865a13b97c02ccf5b03d1e3eaea757d6",
|
"packages/ui/components/Carousel.wrn": "e7d921f802aa19210f3f415b59b70750a2c8d78e30684ad334809741c70a6bf9",
|
||||||
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
|
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
|
||||||
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
|
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
|
||||||
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
|
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
|
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
|
||||||
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
|
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
|
||||||
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
|
"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/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
|
||||||
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
|
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
|
||||||
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
|
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
|
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
|
||||||
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
|
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
|
||||||
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
|
"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/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
|
||||||
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
|
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
|
||||||
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
|
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
|
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
|
||||||
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
|
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
|
||||||
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
|
"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/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
|
||||||
"packages/ui/components/radio.wrn": "09425a78358de5bbd2f47482f313e80065135335b969ce5ccdd5c7cc3ea5232c",
|
"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/skeleton.wrn": "4fc5e0846eeefd7830c038e1789be995c4f9d833aa913ff079eb4863baa65648",
|
||||||
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
|
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
|
||||||
"packages/ui/components/switch.wrn": "874504e4828e9db6a570d78984c4d3a76d0ceb39d70baa877649cb3798c82c85",
|
"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/components/tooltip.wrn": "f3dfdfa5cd9661fe5e95ef3580eef4c7437c069726421370c2d7328fbe7d840d",
|
||||||
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
|
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
|
||||||
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
|
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.8
|
||||||
|
|
||||||
|
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||||
|
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||||
|
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||||
|
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||||
|
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||||
|
stale asynchronous edits, and duplicate client-side suggestions.
|
||||||
|
|
||||||
## 0.8.3
|
## 0.8.3
|
||||||
|
|
||||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||||
@@ -16,8 +25,8 @@
|
|||||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||||
diagnostics in packaged extension environments.
|
diagnostics in packaged extension environments.
|
||||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||||
consistently in the repository and extension development environment.
|
consistently in the repository and extension development environment.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,11 @@
|
|||||||
"maximum": 240,
|
"maximum": 240,
|
||||||
"scope": "resource",
|
"scope": "resource",
|
||||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||||
|
},
|
||||||
|
"wrnexus.html.autoClosingTags": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,9 @@
|
|||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.wrn": "wrn"
|
"*.wrn": "wrn"
|
||||||
},
|
},
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"wrn": "html"
|
||||||
|
},
|
||||||
"[wrn]": {
|
"[wrn]": {
|
||||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
@@ -276,13 +284,14 @@
|
|||||||
"check": "bun run build && bun run test && bun run validate",
|
"check": "bun run build && bun run test && bun run validate",
|
||||||
"vscode:prepublish": "bun run check",
|
"vscode:prepublish": "bun run check",
|
||||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
"publish": "vsce publish --no-dependencies",
|
||||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/vsce": "^3.9.2"
|
"@vscode/vsce": "^3.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-languageclient": "^10.1.0"
|
"vscode-languageclient": "^10.1.0",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const vscode = require("vscode");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-close tags as they are typed.
|
||||||
|
*
|
||||||
|
* LSP has no request for this, so the client watches document changes and asks
|
||||||
|
* the server whether the tag should close. The server owns the decision because
|
||||||
|
* void elements and already-closed tags must not be closed.
|
||||||
|
*/
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn") return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||||
|
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length) return;
|
||||||
|
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/") return;
|
||||||
|
// Every cursor must have typed the same trigger. A replaced selection
|
||||||
|
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Positions come from the editor's selections, not from the changes.
|
||||||
|
*
|
||||||
|
* A change's `range` is in coordinates from before the whole event, so with
|
||||||
|
* several cursors on one line every range after the first is short by the
|
||||||
|
* insertions preceding it. The selections have already been adjusted for
|
||||||
|
* the edit, so they are where the carets actually are.
|
||||||
|
*/
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length) return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||||
|
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(
|
||||||
|
positions.map((position) =>
|
||||||
|
client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||||
|
/*
|
||||||
|
* One insertSnippet call carries one snippet, and it is the only form that
|
||||||
|
* keeps every caret: inserting sequentially would collapse the selection to
|
||||||
|
* the first snippet and invalidate the remaining positions. Cursors that
|
||||||
|
* want different closing tags are therefore declined rather than
|
||||||
|
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||||
|
* this is for, produces one snippet for all of them.
|
||||||
|
*/
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||||
|
|
||||||
|
// The user may have kept typing during the round-trip; re-validate everything the
|
||||||
|
// insertion depends on before touching the document, since a stale offset would
|
||||||
|
// silently corrupt it.
|
||||||
|
if (vscode.window.activeTextEditor !== editor) return;
|
||||||
|
if (editor.document !== event.document) return;
|
||||||
|
if (editor.document.version !== documentVersion) return;
|
||||||
|
if (editor.selections.length !== positions.length) return;
|
||||||
|
if (
|
||||||
|
!editor.selections.every(
|
||||||
|
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAutoCloseTags };
|
||||||
+494
-12
@@ -1,8 +1,8 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: c0e3e8c72c68cb3c182e2de84c13ef0b8921579d9b081550002b8cdbe4af3397
|
// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b
|
||||||
// WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 5.9.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
const __path = __nodeRequire("node:path");
|
const __path = __nodeRequire("node:path");
|
||||||
const __modules = {
|
const __modules = {
|
||||||
@@ -11,6 +11,7 @@ const __modules = {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.optimizeAst = optimizeAst;
|
exports.optimizeAst = optimizeAst;
|
||||||
exports.analyzeOptimizations = analyzeOptimizations;
|
exports.analyzeOptimizations = analyzeOptimizations;
|
||||||
|
exports.routeNeedsIslands = routeNeedsIslands;
|
||||||
exports.analyzeRuntimeRequirements = analyzeRuntimeRequirements;
|
exports.analyzeRuntimeRequirements = analyzeRuntimeRequirements;
|
||||||
function identifiers(value) {
|
function identifiers(value) {
|
||||||
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
||||||
@@ -191,8 +192,18 @@ function hasEvent(nodes) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function analyzeRuntimeRequirements(ast) {
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function routeNeedsIslands(imports) {
|
||||||
|
return imports.some((entry) => entry.kind === "island");
|
||||||
|
}
|
||||||
|
function analyzeRuntimeRequirements(ast, options = {}) {
|
||||||
|
const hasIslands = options.hasIslands ?? false;
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
|
if (hasIslands)
|
||||||
|
reasons.push("react island");
|
||||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||||
const interactive = clientFunctions ||
|
const interactive = clientFunctions ||
|
||||||
@@ -246,11 +257,16 @@ function analyzeRuntimeRequirements(ast) {
|
|||||||
kind = "streaming-ssr";
|
kind = "streaming-ssr";
|
||||||
reasons.push("partial-static shell with streamed dynamic regions");
|
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 clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||||
const serverDisabled = ast.renderMode === "client";
|
const serverDisabled = ast.renderMode === "client";
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
canPrerender: kind === "static" || kind === "static-interactive",
|
canPrerender: kind === "static" || kind === "static-interactive",
|
||||||
|
needsIslandRuntime: hasIslands,
|
||||||
needsClientRuntime: !clientDisabled &&
|
needsClientRuntime: !clientDisabled &&
|
||||||
(interactive || ast.renderMode === "client") &&
|
(interactive || ast.renderMode === "client") &&
|
||||||
ast.hydrate !== "none" &&
|
ast.hydrate !== "none" &&
|
||||||
@@ -269,6 +285,34 @@ function analyzeRuntimeRequirements(ast) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/compiler/src/browser-transpile.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.stripBrowserTypes = stripBrowserTypes;
|
||||||
|
let transpiler = null;
|
||||||
|
function stripBrowserTypes(code) {
|
||||||
|
const bun = globalThis.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);
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -773,7 +817,46 @@ const types_ts_1 = require("./types.js");
|
|||||||
const syntax_1 = require("@wrnexus/syntax");
|
const syntax_1 = require("@wrnexus/syntax");
|
||||||
const store_codegen_ts_1 = require("./store-codegen.js");
|
const store_codegen_ts_1 = require("./store-codegen.js");
|
||||||
const analysis_ts_1 = require("./analysis.js");
|
const analysis_ts_1 = require("./analysis.js");
|
||||||
|
const island_codegen_ts_1 = require("./island-codegen.js");
|
||||||
const client_codegen_ts_1 = require("./client-codegen.js");
|
const client_codegen_ts_1 = require("./client-codegen.js");
|
||||||
|
/**
|
||||||
|
* 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 = new Set();
|
||||||
|
/**
|
||||||
|
* Builds the island placeholder for a component tag that was imported from a
|
||||||
|
* .tsx file. Returns null for ordinary .wrn components.
|
||||||
|
*/
|
||||||
|
function islandMarkerFor(node) {
|
||||||
|
if (!currentIslands.has(node.tag))
|
||||||
|
return null;
|
||||||
|
const directives = node.attrs
|
||||||
|
.map((attr) => attr.name)
|
||||||
|
.filter((name) => name.startsWith("client:"));
|
||||||
|
const props = {};
|
||||||
|
for (const attr of node.attrs) {
|
||||||
|
if (attr.name.startsWith("client:"))
|
||||||
|
continue;
|
||||||
|
const parsed = (0, island_codegen_ts_1.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 = (0, island_codegen_ts_1.serializeIslandProps)(node.tag, props);
|
||||||
|
if ("diagnostic" in serialized)
|
||||||
|
throw new Error(serialized.diagnostic.message);
|
||||||
|
return (0, island_codegen_ts_1.renderIslandMarker)({
|
||||||
|
name: node.tag,
|
||||||
|
strategy: (0, island_codegen_ts_1.parseIslandStrategy)(directives),
|
||||||
|
propsJson: serialized.json,
|
||||||
|
});
|
||||||
|
}
|
||||||
function isComponentTag(tag) {
|
function isComponentTag(tag) {
|
||||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||||
}
|
}
|
||||||
@@ -1121,6 +1204,9 @@ function renderLoopBody(node) {
|
|||||||
escLit("</div>"));
|
escLit("</div>"));
|
||||||
}
|
}
|
||||||
if (componentTag) {
|
if (componentTag) {
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island)
|
||||||
|
return escLit(island);
|
||||||
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||||
attrs +
|
attrs +
|
||||||
escLit(">") +
|
escLit(">") +
|
||||||
@@ -1203,7 +1289,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
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") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
const inner = node.children
|
const inner = node.children
|
||||||
@@ -1314,6 +1414,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||||
}
|
}
|
||||||
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island)
|
||||||
|
return island;
|
||||||
const attrs = node.attrs
|
const attrs = node.attrs
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||||
@@ -1324,6 +1427,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin
|
|||||||
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
|
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
|
||||||
}
|
}
|
||||||
function renderNestedComponentInvocation(node, ctx) {
|
function renderNestedComponentInvocation(node, ctx) {
|
||||||
|
// Islands work inside .wrn components too, not just pages.
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island)
|
||||||
|
return island;
|
||||||
let bindIndex = 0;
|
let bindIndex = 0;
|
||||||
const attrs = node.attrs
|
const attrs = node.attrs
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
@@ -1780,7 +1887,16 @@ function markServerAsyncBoundaries(nodes, serverLoads) {
|
|||||||
markServerAsyncBoundaries(node.children, serverLoads);
|
markServerAsyncBoundaries(node.children, serverLoads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function generate(ast) {
|
function generate(ast, options = {}) {
|
||||||
|
currentIslands = options.islands ?? new Set();
|
||||||
|
try {
|
||||||
|
return generateInner(ast);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
currentIslands = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function generateInner(ast) {
|
||||||
ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
|
ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
|
||||||
if (ast.kind === "global-store" || ast.kind === "page-store")
|
if (ast.kind === "global-store" || ast.kind === "page-store")
|
||||||
return (0, store_codegen_ts_1.generateStoreModule)(ast);
|
return (0, store_codegen_ts_1.generateStoreModule)(ast);
|
||||||
@@ -2351,6 +2467,76 @@ function compileAttrValue(raw, ctx) {
|
|||||||
}
|
}
|
||||||
return out + escLit(attrEscape(raw.slice(last)));
|
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) {
|
||||||
|
const render = (node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
|
||||||
|
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 (parser_ts_1.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) {
|
||||||
|
return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
function renderComponentIfNode(node, ctx) {
|
function renderComponentIfNode(node, ctx) {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||||
@@ -2362,7 +2548,11 @@ function renderComponentIfNode(node, ctx) {
|
|||||||
? bodyExpression
|
? bodyExpression
|
||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${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, ctx) {
|
function renderComponentEachNode(node, ctx) {
|
||||||
const item = node.item;
|
const item = node.item;
|
||||||
@@ -2374,7 +2564,7 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
};
|
};
|
||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
return ("${(() => { const __wl = Array.isArray(" +
|
const serverBody = "${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
list +
|
list +
|
||||||
@@ -2386,7 +2576,16 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
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) {
|
function serverLoopLocalsAttribute(ctx) {
|
||||||
const locals = [...(ctx.serverLocals ?? [])];
|
const locals = [...(ctx.serverLocals ?? [])];
|
||||||
@@ -3107,9 +3306,11 @@ function candidates(path) {
|
|||||||
path,
|
path,
|
||||||
`${path}.wrn`,
|
`${path}.wrn`,
|
||||||
`${path}.ts`,
|
`${path}.ts`,
|
||||||
|
`${path}.tsx`,
|
||||||
`${path}.d.ts`,
|
`${path}.d.ts`,
|
||||||
(0, node_path_1.join)(path, "index.wrn"),
|
(0, node_path_1.join)(path, "index.wrn"),
|
||||||
(0, node_path_1.join)(path, "index.ts"),
|
(0, node_path_1.join)(path, "index.ts"),
|
||||||
|
(0, node_path_1.join)(path, "index.tsx"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function resolveWrnImport(declaration, importer, options) {
|
function resolveWrnImport(declaration, importer, options) {
|
||||||
@@ -3136,8 +3337,12 @@ function resolveWrnImport(declaration, importer, options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (found)
|
if (found) {
|
||||||
return { declaration, resolved: (0, node_fs_1.realpathSync)(found) };
|
const resolved = (0, node_fs_1.realpathSync)(found);
|
||||||
|
return resolved.endsWith(".tsx")
|
||||||
|
? { declaration, resolved, kind: "island" }
|
||||||
|
: { declaration, resolved };
|
||||||
|
}
|
||||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||||
return {
|
return {
|
||||||
declaration,
|
declaration,
|
||||||
@@ -3162,7 +3367,7 @@ function resolveWrnImports(declarations, importer, options) {
|
|||||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.stripBrowserTypes = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||||
exports.compileNativeWrnFile = compileNativeWrnFile;
|
exports.compileNativeWrnFile = compileNativeWrnFile;
|
||||||
exports.compileWrnFile = compileWrnFile;
|
exports.compileWrnFile = compileWrnFile;
|
||||||
exports.compile = compile;
|
exports.compile = compile;
|
||||||
@@ -3184,6 +3389,8 @@ var targets_ts_1 = require("./targets.js");
|
|||||||
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
||||||
var client_codegen_ts_1 = require("./client-codegen.js");
|
var client_codegen_ts_1 = require("./client-codegen.js");
|
||||||
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
||||||
|
var browser_transpile_ts_1 = require("./browser-transpile.js");
|
||||||
|
Object.defineProperty(exports, "stripBrowserTypes", { enumerable: true, get: function () { return browser_transpile_ts_1.stripBrowserTypes; } });
|
||||||
var server_codegen_ts_1 = require("./server-codegen.js");
|
var server_codegen_ts_1 = require("./server-codegen.js");
|
||||||
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
||||||
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
||||||
@@ -3258,6 +3465,281 @@ var cache_ts_1 = require("./cache.js");
|
|||||||
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
|
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
|
||||||
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
|
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
|
||||||
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
|
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
|
||||||
|
var island_codegen_ts_1 = require("./island-codegen.js");
|
||||||
|
Object.defineProperty(exports, "islandNamesFrom", { enumerable: true, get: function () { return island_codegen_ts_1.islandNamesFrom; } });
|
||||||
|
Object.defineProperty(exports, "islandPropValue", { enumerable: true, get: function () { return island_codegen_ts_1.islandPropValue; } });
|
||||||
|
Object.defineProperty(exports, "parseIslandStrategy", { enumerable: true, get: function () { return island_codegen_ts_1.parseIslandStrategy; } });
|
||||||
|
Object.defineProperty(exports, "renderIslandMarker", { enumerable: true, get: function () { return island_codegen_ts_1.renderIslandMarker; } });
|
||||||
|
Object.defineProperty(exports, "serializeIslandProps", { enumerable: true, get: function () { return island_codegen_ts_1.serializeIslandProps; } });
|
||||||
|
var island_bundle_ts_1 = require("./island-bundle.js");
|
||||||
|
Object.defineProperty(exports, "assertReactAvailable", { enumerable: true, get: function () { return island_bundle_ts_1.assertReactAvailable; } });
|
||||||
|
Object.defineProperty(exports, "buildIslands", { enumerable: true, get: function () { return island_bundle_ts_1.buildIslands; } });
|
||||||
|
Object.defineProperty(exports, "generateIslandEntry", { enumerable: true, get: function () { return island_bundle_ts_1.generateIslandEntry; } });
|
||||||
|
var analysis_ts_2 = require("./analysis.js");
|
||||||
|
Object.defineProperty(exports, "routeNeedsIslands", { enumerable: true, get: function () { return analysis_ts_2.routeNeedsIslands; } });
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/compiler/src/island-bundle.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.generateIslandEntry = generateIslandEntry;
|
||||||
|
exports.reactJsxPlugin = reactJsxPlugin;
|
||||||
|
exports.assertReactAvailable = assertReactAvailable;
|
||||||
|
exports.buildIslands = buildIslands;
|
||||||
|
const node_crypto_1 = require("node:crypto");
|
||||||
|
const node_fs_1 = require("node:fs");
|
||||||
|
const node_module_1 = require("node:module");
|
||||||
|
const node_path_1 = require("node:path");
|
||||||
|
/**
|
||||||
|
* Generates the per-island browser entry.
|
||||||
|
*
|
||||||
|
* Never imports react-dom/server — islands are client-only.
|
||||||
|
*/
|
||||||
|
function generateIslandEntry(input) {
|
||||||
|
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`.
|
||||||
|
*/
|
||||||
|
function reactJsxPlugin() {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function assertReactAvailable(appRoot) {
|
||||||
|
const require = (0, node_module_1.createRequire)((0, node_path_1.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.
|
||||||
|
*/
|
||||||
|
async function buildIslands(input) {
|
||||||
|
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 = (0, node_path_1.join)(input.outDir, ".entries");
|
||||||
|
(0, node_fs_1.mkdirSync)(entryDir, { recursive: true });
|
||||||
|
const islandNames = new Set(input.islands.map((island) => island.name));
|
||||||
|
for (const island of input.islands) {
|
||||||
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
||||||
|
}
|
||||||
|
const entrypoints = input.islands.map((island) => (0, node_path_1.join)(entryDir, `${island.name}.tsx`));
|
||||||
|
if (input.appRoot) {
|
||||||
|
const resolveFrom = (0, node_module_1.createRequire)((0, node_path_1.join)(input.appRoot, "package.json"));
|
||||||
|
const runtimeEntry = (0, node_path_1.join)(entryDir, "runtime.ts");
|
||||||
|
(0, node_fs_1.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 = [];
|
||||||
|
const sharedChunks = [];
|
||||||
|
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 = (0, node_path_1.basename)(output.path).replace(/\.js$/, "");
|
||||||
|
if (stem === "runtime")
|
||||||
|
continue;
|
||||||
|
if (!islandNames.has(stem))
|
||||||
|
continue;
|
||||||
|
assets.push({
|
||||||
|
name: stem,
|
||||||
|
hash: (0, node_crypto_1.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 {
|
||||||
|
(0, node_fs_1.rmSync)(entryDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/compiler/src/island-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.parseIslandStrategy = parseIslandStrategy;
|
||||||
|
exports.islandPropValue = islandPropValue;
|
||||||
|
exports.serializeIslandProps = serializeIslandProps;
|
||||||
|
exports.renderIslandMarker = renderIslandMarker;
|
||||||
|
exports.islandNamesFrom = islandNamesFrom;
|
||||||
|
// 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 = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
|
||||||
|
}
|
||||||
|
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
|
||||||
|
function isSafeIslandName(name) {
|
||||||
|
return SAFE_ISLAND_NAME.test(name);
|
||||||
|
}
|
||||||
|
const STRATEGIES = {
|
||||||
|
"client:only": "only",
|
||||||
|
"client:load": "load",
|
||||||
|
"client:visible": "visible",
|
||||||
|
"client:idle": "idle",
|
||||||
|
};
|
||||||
|
function parseIslandStrategy(directives) {
|
||||||
|
for (const directive of directives) {
|
||||||
|
const match = STRATEGIES[directive];
|
||||||
|
if (match)
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return "only";
|
||||||
|
}
|
||||||
|
function unsupportedProp(value) {
|
||||||
|
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.some(unsupportedProp);
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
if (proto !== Object.prototype && proto !== null)
|
||||||
|
return true;
|
||||||
|
return Object.values(value).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.
|
||||||
|
*/
|
||||||
|
function islandPropValue(raw) {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { dynamic: inner };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function serializeIslandProps(componentName, props) {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
function renderIslandMarker(input) {
|
||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
function islandNamesFrom(imports) {
|
||||||
|
const names = new Set();
|
||||||
|
for (const entry of imports) {
|
||||||
|
if (entry.kind !== "island")
|
||||||
|
continue;
|
||||||
|
const local = entry.declaration.defaultImport;
|
||||||
|
if (local)
|
||||||
|
names.add(local);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
|||||||
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
|
|||||||
return depth > 0;
|
return depth > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an offset sits inside a `view { }` block.
|
||||||
|
*
|
||||||
|
* The language server owns completion there and returns a merged list, so this
|
||||||
|
* provider stands down to avoid VS Code concatenating two independent lists.
|
||||||
|
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
|
||||||
|
* string that never closes.
|
||||||
|
*/
|
||||||
|
function isInsideViewBlock(text, offset) {
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote = null;
|
||||||
|
let index = start;
|
||||||
|
for (; index < text.length && depth > 0; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) quote = char;
|
||||||
|
else if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") depth -= 1;
|
||||||
|
}
|
||||||
|
if (offset >= start && offset <= index) return true;
|
||||||
|
pattern.lastIndex = index;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isAfterWatchKeyword(document, position) {
|
function isAfterWatchKeyword(document, position) {
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
|
||||||
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function provideCompletionItems(document, position) {
|
function provideCompletionItems(document, position) {
|
||||||
|
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
@@ -707,6 +744,7 @@ module.exports = {
|
|||||||
extractProps,
|
extractProps,
|
||||||
extractRouteParams,
|
extractRouteParams,
|
||||||
extractStates,
|
extractStates,
|
||||||
|
isInsideViewBlock,
|
||||||
provideCompletionItems,
|
provideCompletionItems,
|
||||||
registerCompletionProvider,
|
registerCompletionProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 28a3937b948e6affb33150753d537162c6702786551dd90ab0968ef9166f21ac
|
// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// editors/vscode/src/auto-close-tags.js
|
||||||
|
var require_auto_close_tags = __commonJS((exports2, module2) => {
|
||||||
|
var vscode = require("vscode");
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn")
|
||||||
|
return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
|
||||||
|
return;
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length)
|
||||||
|
return;
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/")
|
||||||
|
return;
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
|
||||||
|
return;
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty))
|
||||||
|
return;
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character }
|
||||||
|
})));
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
|
||||||
|
return;
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0]))
|
||||||
|
return;
|
||||||
|
if (vscode.window.activeTextEditor !== editor)
|
||||||
|
return;
|
||||||
|
if (editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
if (editor.document.version !== documentVersion)
|
||||||
|
return;
|
||||||
|
if (editor.selections.length !== positions.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
module2.exports = { registerAutoCloseTags };
|
||||||
|
});
|
||||||
|
|
||||||
// editors/vscode/src/extension.js
|
// editors/vscode/src/extension.js
|
||||||
var path = require("node:path");
|
var path = require("node:path");
|
||||||
var vscode = require("vscode");
|
var vscode = require("vscode");
|
||||||
var { LanguageClient, TransportKind } = require_main5();
|
var { LanguageClient, TransportKind } = require_main5();
|
||||||
|
var { registerAutoCloseTags } = require_auto_close_tags();
|
||||||
var WRN_LANGUAGE_ID = "wrn";
|
var WRN_LANGUAGE_ID = "wrn";
|
||||||
var client;
|
var client;
|
||||||
async function recoverWrnLanguage(document) {
|
async function recoverWrnLanguage(document) {
|
||||||
@@ -22730,6 +22783,7 @@ async function activate(context) {
|
|||||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
const running = client;
|
const running = client;
|
||||||
@@ -22737,4 +22791,4 @@ async function deactivate() {
|
|||||||
if (running)
|
if (running)
|
||||||
await running.stop();
|
await running.stop();
|
||||||
}
|
}
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vscode = require("vscode");
|
const vscode = require("vscode");
|
||||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||||
|
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||||
|
|
||||||
const WRN_LANGUAGE_ID = "wrn";
|
const WRN_LANGUAGE_ID = "wrn";
|
||||||
/** @type {LanguageClient | undefined} */
|
/** @type {LanguageClient | undefined} */
|
||||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
|||||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||||
);
|
);
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
|||||||
if (running) await running.stop();
|
if (running) await running.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
+24850
-1611
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 assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
// These extraction helpers are pure, but their module also registers VS Code
|
// These extraction helpers are pure, but their module also registers VS Code
|
||||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({});
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") return {};
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
test("extracts dynamic route params from filename", () => {
|
test("extracts dynamic route params from filename", () => {
|
||||||
const document = {
|
const document = {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") {
|
|
||||||
return {
|
|
||||||
Diagnostic: class Diagnostic {
|
Diagnostic: class Diagnostic {
|
||||||
constructor(range, message, severity) {
|
constructor(range, message, severity) {
|
||||||
this.range = range;
|
this.range = range;
|
||||||
@@ -22,10 +19,7 @@ Module._load = function load(request, parent, isMain) {
|
|||||||
this.end = end;
|
this.end = end;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
findTopLevelDeclaration,
|
findTopLevelDeclaration,
|
||||||
maskLeadingTrivia,
|
maskLeadingTrivia,
|
||||||
@@ -34,7 +28,7 @@ const {
|
|||||||
validateLayoutUsage,
|
validateLayoutUsage,
|
||||||
validateRootMembers,
|
validateRootMembers,
|
||||||
} = require("../src/diagnostics");
|
} = require("../src/diagnostics");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
function mockDocument() {
|
function mockDocument() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ try {
|
|||||||
readFileSync(join(root, rel), "utf8");
|
readFileSync(join(root, rel), "utf8");
|
||||||
ok(`Marketplace document exists: ${rel}`);
|
ok(`Marketplace document exists: ${rel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||||
|
emmetLanguages?.wrn === "html"
|
||||||
|
? ok("Emmet is mapped for wrn documents")
|
||||||
|
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||||
|
|
||||||
|
const autoClose =
|
||||||
|
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||||
|
autoClose?.type === "boolean" && autoClose?.default === true
|
||||||
|
? ok("auto-closing tags setting is contributed")
|
||||||
|
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||||
|
|
||||||
|
manifest.dependencies?.["vscode-html-languageservice"]
|
||||||
|
? ok("HTML language service ships as a runtime dependency")
|
||||||
|
: bad("HTML language service ships as a runtime dependency");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bad("Marketplace metadata", e.message);
|
bad("Marketplace metadata", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||||
|
*
|
||||||
|
* These files run under `node --test` (see the package's test script), where
|
||||||
|
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||||
|
* root also picks them up by filename, and Bun resolves `require` through its
|
||||||
|
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||||
|
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||||
|
* module covers that case, leaving one shim that works under both runners.
|
||||||
|
*
|
||||||
|
* Returns a function restoring the original loader.
|
||||||
|
*/
|
||||||
|
function installVsCodeHost(stub) {
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
if (typeof Bun !== "undefined") {
|
||||||
|
require("bun").plugin({
|
||||||
|
name: "vscode-host-stub",
|
||||||
|
setup(build) {
|
||||||
|
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === "vscode") return stub;
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { installVsCodeHost };
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||||
import type { Db, ExecResult } from "@wrnexus/db";
|
import type { Db, ExecResult } from "@wrnexus/db";
|
||||||
import { users } from "./schema.ts";
|
import { users } from "./schema.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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,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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface Routes {
|
|||||||
"/client-only": Record<string, never>;
|
"/client-only": Record<string, never>;
|
||||||
"/dashboard": Record<string, never>;
|
"/dashboard": Record<string, never>;
|
||||||
"/hello": Record<string, never>;
|
"/hello": Record<string, never>;
|
||||||
|
"/island-demo": Record<string, never>;
|
||||||
"/language-tools": Record<string, never>;
|
"/language-tools": Record<string, never>;
|
||||||
"/layout": Record<string, never>;
|
"/layout": Record<string, never>;
|
||||||
"/login": Record<string, never>;
|
"/login": Record<string, never>;
|
||||||
@@ -31,6 +32,7 @@ export interface RouteNames {
|
|||||||
"client.only": "/client-only";
|
"client.only": "/client-only";
|
||||||
"dashboard": "/dashboard";
|
"dashboard": "/dashboard";
|
||||||
"hello": "/hello";
|
"hello": "/hello";
|
||||||
|
"island.demo": "/island-demo";
|
||||||
"language.tools": "/language-tools";
|
"language.tools": "/language-tools";
|
||||||
"layout": "/layout";
|
"layout": "/layout";
|
||||||
"login": "/login";
|
"login": "/login";
|
||||||
@@ -122,6 +124,7 @@ export function route<N extends RouteName>(
|
|||||||
"client.only": "/client-only",
|
"client.only": "/client-only",
|
||||||
"dashboard": "/dashboard",
|
"dashboard": "/dashboard",
|
||||||
"hello": "/hello",
|
"hello": "/hello",
|
||||||
|
"island.demo": "/island-demo",
|
||||||
"language.tools": "/language-tools",
|
"language.tools": "/language-tools",
|
||||||
"layout": "/layout",
|
"layout": "/layout",
|
||||||
"login": "/login",
|
"login": "/login",
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type 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 RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/test": "workspace:*",
|
"@wrnexus/test": "workspace:*",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/pubsub": "0.8.6"
|
"@wrnexus/pubsub": "^0.8.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -71,12 +71,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^10.8.0",
|
"@types/react": "^19.2.18",
|
||||||
"happy-dom": "^20.11.1",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"eslint": "^10.8.1",
|
||||||
|
"happy-dom": "^20.11.2",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.3.0"
|
"bun": ">=1.3.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"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.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/captcha",
|
"name": "@wrnexus/captcha",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.32",
|
"version": "0.8.45",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@wrnexus/db": "workspace:*",
|
"@wrnexus/db": "workspace:*",
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
|
|||||||
+72
-12
@@ -24,9 +24,13 @@ import {
|
|||||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||||
import { buildRouter, type Route } from "@wrnexus/router";
|
import { buildRouter, type Route } from "@wrnexus/router";
|
||||||
import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
|
import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
|
||||||
|
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||||
import {
|
import {
|
||||||
analyzeRuntimeImports,
|
analyzeRuntimeImports,
|
||||||
analyzeRuntimeRequirements,
|
analyzeRuntimeRequirements,
|
||||||
|
assertReactAvailable,
|
||||||
|
buildIslands,
|
||||||
|
islandNamesFrom,
|
||||||
assertValidAst,
|
assertValidAst,
|
||||||
generate,
|
generate,
|
||||||
generateTargets,
|
generateTargets,
|
||||||
@@ -35,6 +39,7 @@ import {
|
|||||||
type DeploymentRuntime,
|
type DeploymentRuntime,
|
||||||
runtimeCapabilities,
|
runtimeCapabilities,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
import {
|
import {
|
||||||
loadAppConfig,
|
loadAppConfig,
|
||||||
@@ -112,6 +117,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
const clientModulesDir = join(distDir, "client");
|
const clientModulesDir = join(distDir, "client");
|
||||||
const reactivePath = join(distDir, "reactive.js");
|
const reactivePath = join(distDir, "reactive.js");
|
||||||
const controllersPath = join(distDir, "controllers.js");
|
const controllersPath = join(distDir, "controllers.js");
|
||||||
|
const islandsPath = join(distDir, "islands.js");
|
||||||
const publicDir = join(root, "public");
|
const publicDir = join(root, "public");
|
||||||
const distPublicDir = join(distDir, "public");
|
const distPublicDir = join(distDir, "public");
|
||||||
const config = await loadAppConfig(root);
|
const config = await loadAppConfig(root);
|
||||||
@@ -183,6 +189,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
const clientFiles = new Map<string, string>();
|
const clientFiles = new Map<string, string>();
|
||||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||||
const partialStaticFiles = new Set<string>();
|
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> => {
|
const compileWrn = async (file: string): Promise<void> => {
|
||||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||||
const source = readFileSync(file, "utf8");
|
const source = readFileSync(file, "utf8");
|
||||||
@@ -207,7 +215,25 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
assertValidAst(ast, { file, accessibility: true });
|
assertValidAst(ast, { file, accessibility: true });
|
||||||
ast = await pluginRunner.transformAst(ast, file);
|
ast = await pluginRunner.transformAst(ast, file);
|
||||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(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 pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||||
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
||||||
@@ -221,7 +247,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const targets = generateTargets(ast);
|
const targets = generateTargets(ast);
|
||||||
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
|
let code =
|
||||||
|
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
|
||||||
"__WRNEXUS_CLIENT_MODULE__",
|
"__WRNEXUS_CLIENT_MODULE__",
|
||||||
clientUrl,
|
clientUrl,
|
||||||
);
|
);
|
||||||
@@ -247,11 +274,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
const resolvedImports = fileImports;
|
||||||
appRoot: root,
|
|
||||||
mode: config.imports?.mode ?? "compatible",
|
|
||||||
aliases: config.imports?.aliases,
|
|
||||||
});
|
|
||||||
for (const imported of resolvedImports) {
|
for (const imported of resolvedImports) {
|
||||||
if (imported.diagnostic?.severity === "error") {
|
if (imported.diagnostic?.severity === "error") {
|
||||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
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(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({
|
const browserResult = await Bun.build({
|
||||||
entrypoints: [clientEntry],
|
entrypoints: [clientEntry],
|
||||||
target: "browser",
|
target: "browser",
|
||||||
@@ -493,6 +518,36 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
assetHash.update(controllerCode);
|
assetHash.update(controllerCode);
|
||||||
console.log(`✓ Controllers: ${controllersPath}`);
|
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).
|
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||||
const theme = resolveThemeConfig(config.theme, config.cookies);
|
const theme = resolveThemeConfig(config.theme, config.cookies);
|
||||||
const themeCss = renderThemeCss(theme);
|
const themeCss = renderThemeCss(theme);
|
||||||
@@ -568,10 +623,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
appDir,
|
appDir,
|
||||||
appRoot: root,
|
appRoot: root,
|
||||||
mode: "production",
|
mode: "production",
|
||||||
sources: [
|
// Component discovery is not style discovery. Built-in and plugin
|
||||||
...componentDirs,
|
// components own their CSS; scanning every available component makes
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
// 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] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
config.styles,
|
config.styles,
|
||||||
@@ -735,6 +794,7 @@ await createProductionServer(
|
|||||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||||
controllersPath: join(import.meta.dir, "controllers.js"),
|
controllersPath: join(import.meta.dir, "controllers.js"),
|
||||||
clientModulesDir: join(import.meta.dir, "client"),
|
clientModulesDir: join(import.meta.dir, "client"),
|
||||||
|
islandsDir: join(import.meta.dir, "island"),
|
||||||
themePath: join(import.meta.dir, "theme.css"),
|
themePath: join(import.meta.dir, "theme.css"),
|
||||||
themeAssetsDir: join(import.meta.dir, "theme"),
|
themeAssetsDir: join(import.meta.dir, "theme"),
|
||||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||||
|
|||||||
@@ -121,16 +121,16 @@ Thumbs.db
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "${cliVersion}",
|
"@wrnexus/cli": "${cliVersion}",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "latest",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "latest"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
|
|||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@capacitor/cli": "^8.0.0",
|
"@capacitor/cli": "^8.0.0",
|
||||||
"@capacitor/assets": "^3.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-safe-area-context": "^5.6.0",
|
||||||
"react-native-screens": "^4.23.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 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`;
|
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/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
},
|
},
|
||||||
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
|
devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
|
||||||
wrnexus: {
|
wrnexus: {
|
||||||
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
|
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { scaffoldApp } from "./create.ts";
|
import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
|
||||||
import { currentCliVersion } from "./update-notifier.ts";
|
import { currentCliVersion } from "./update-notifier.ts";
|
||||||
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
||||||
|
|
||||||
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "${frameworkVersion}",
|
"@wrnexus/cli": "${frameworkVersion}",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "latest",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "latest"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -250,7 +250,7 @@ export default tseslint.config(
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/pubsub": "${frameworkVersion}"
|
"@wrnexus/pubsub": "${scaffoldFrameworkRange}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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["@wrnexus/cli"]).toBe(version);
|
||||||
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
|
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");
|
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||||
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
|
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
|
||||||
expect(globalCss).toContain('"Plus Jakarta Sans"');
|
expect(globalCss).toContain('"Plus Jakarta Sans"');
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (port === undefined) {
|
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}/`);
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { currentCliVersion } from "../src/update-notifier.ts";
|
import { currentCliVersion } from "../src/update-notifier.ts";
|
||||||
|
import { scaffoldFrameworkRange } from "../src/create.ts";
|
||||||
import {
|
import {
|
||||||
addWorkspaceApp,
|
addWorkspaceApp,
|
||||||
insertWorkspaceApp,
|
insertWorkspaceApp,
|
||||||
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
|
|||||||
expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com");
|
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 files = workspaceFiles("acme");
|
||||||
const rootPackage = JSON.parse(files["package.json"]!);
|
const rootPackage = JSON.parse(files["package.json"]!);
|
||||||
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
|
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
|
||||||
const version = currentCliVersion();
|
const version = currentCliVersion();
|
||||||
|
|
||||||
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
|
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("http://127.0.0.1:3000");
|
||||||
expect(files["README.md"]).toContain("internal gateway targets");
|
expect(files["README.md"]).toContain("internal gateway targets");
|
||||||
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
|
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
||||||
|
import type { ResolvedImport } from "./import-resolver.ts";
|
||||||
|
|
||||||
export type RouteExecutionKind =
|
export type RouteExecutionKind =
|
||||||
| "static"
|
| "static"
|
||||||
@@ -12,6 +13,8 @@ export interface RuntimeRequirements {
|
|||||||
kind: RouteExecutionKind;
|
kind: RouteExecutionKind;
|
||||||
canPrerender: boolean;
|
canPrerender: boolean;
|
||||||
needsClientRuntime: boolean;
|
needsClientRuntime: boolean;
|
||||||
|
/** True when the route mounts a React island and must ship the island runtime. */
|
||||||
|
needsIslandRuntime: boolean;
|
||||||
needsServerRuntime: boolean;
|
needsServerRuntime: boolean;
|
||||||
hydrationStrategy: string | null;
|
hydrationStrategy: string | null;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
@@ -210,8 +213,21 @@ function hasEvent(nodes: ViewNode[]): boolean {
|
|||||||
return false;
|
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[] = [];
|
const reasons: string[] = [];
|
||||||
|
if (hasIslands) reasons.push("react island");
|
||||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||||
const interactive =
|
const interactive =
|
||||||
@@ -260,12 +276,17 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
|||||||
reasons.push("partial-static shell with streamed dynamic regions");
|
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 clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||||
const serverDisabled = ast.renderMode === "client";
|
const serverDisabled = ast.renderMode === "client";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
canPrerender: kind === "static" || kind === "static-interactive",
|
canPrerender: kind === "static" || kind === "static-interactive",
|
||||||
|
needsIslandRuntime: hasIslands,
|
||||||
needsClientRuntime:
|
needsClientRuntime:
|
||||||
!clientDisabled &&
|
!clientDisabled &&
|
||||||
(interactive || ast.renderMode === "client") &&
|
(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);
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
|||||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||||
import { generateStoreModule } from "./store-codegen.ts";
|
import { generateStoreModule } from "./store-codegen.ts";
|
||||||
import { optimizeAst } from "./analysis.ts";
|
import { optimizeAst } from "./analysis.ts";
|
||||||
|
import {
|
||||||
|
islandPropValue,
|
||||||
|
parseIslandStrategy,
|
||||||
|
renderIslandMarker,
|
||||||
|
serializeIslandProps,
|
||||||
|
} from "./island-codegen.ts";
|
||||||
import { browserModuleRequired } from "./client-codegen.ts";
|
import { browserModuleRequired } from "./client-codegen.ts";
|
||||||
|
|
||||||
interface RenderBinding {
|
interface RenderBinding {
|
||||||
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
|
|||||||
mode: DataMode;
|
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 {
|
function isComponentTag(tag: string): boolean {
|
||||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||||
}
|
}
|
||||||
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (componentTag) {
|
if (componentTag) {
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island) return escLit(island);
|
||||||
return (
|
return (
|
||||||
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||||
attrs +
|
attrs +
|
||||||
@@ -543,7 +593,22 @@ function renderNode(
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
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") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
@@ -704,6 +769,9 @@ function renderPageComponentInvocation(
|
|||||||
loops: string[],
|
loops: string[],
|
||||||
reactive: PageReactive | null,
|
reactive: PageReactive | null,
|
||||||
): string {
|
): string {
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island) return island;
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = node.attrs
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||||
@@ -720,6 +788,10 @@ function renderNestedComponentInvocation(
|
|||||||
node: Extract<ViewNode, { type: "element" }>,
|
node: Extract<ViewNode, { type: "element" }>,
|
||||||
ctx: CompCtx,
|
ctx: CompCtx,
|
||||||
): string {
|
): string {
|
||||||
|
// Islands work inside .wrn components too, not just pages.
|
||||||
|
const island = islandMarkerFor(node);
|
||||||
|
if (island) return island;
|
||||||
|
|
||||||
let bindIndex = 0;
|
let bindIndex = 0;
|
||||||
|
|
||||||
const attrs = node.attrs
|
const attrs = node.attrs
|
||||||
@@ -1255,7 +1327,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;
|
ast = optimizeAst(ast).ast;
|
||||||
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
|
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
|
||||||
if (ast.kind === "component" || ast.kind === "layout") {
|
if (ast.kind === "component" || ast.kind === "layout") {
|
||||||
@@ -1966,6 +2052,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
|
|||||||
return out + escLit(attrEscape(raw.slice(last)));
|
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 {
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
|
|
||||||
@@ -1980,7 +2147,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
|||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${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 {
|
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||||
@@ -1996,7 +2170,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
|
|
||||||
return (
|
const serverBody =
|
||||||
"${(() => { const __wl = Array.isArray(" +
|
"${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
@@ -2009,8 +2183,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
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 {
|
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface ImportResolverOptions {
|
|||||||
export interface ResolvedImport {
|
export interface ResolvedImport {
|
||||||
declaration: StructuredImportDecl;
|
declaration: StructuredImportDecl;
|
||||||
resolved?: string;
|
resolved?: string;
|
||||||
|
/** `.tsx` imports are React islands, not `.wrn` components. */
|
||||||
|
kind?: "island";
|
||||||
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
|
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +23,11 @@ function candidates(path: string): string[] {
|
|||||||
path,
|
path,
|
||||||
`${path}.wrn`,
|
`${path}.wrn`,
|
||||||
`${path}.ts`,
|
`${path}.ts`,
|
||||||
|
`${path}.tsx`,
|
||||||
`${path}.d.ts`,
|
`${path}.d.ts`,
|
||||||
join(path, "index.wrn"),
|
join(path, "index.wrn"),
|
||||||
join(path, "index.ts"),
|
join(path, "index.ts"),
|
||||||
|
join(path, "index.tsx"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +60,12 @@ export function resolveWrnImport(
|
|||||||
return false;
|
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";
|
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||||
return {
|
return {
|
||||||
declaration,
|
declaration,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
export { generate } from "./codegen.ts";
|
export { generate } from "./codegen.ts";
|
||||||
export { generateTargets } from "./targets.ts";
|
export { generateTargets } from "./targets.ts";
|
||||||
export { generateBrowserModule } from "./client-codegen.ts";
|
export { generateBrowserModule } from "./client-codegen.ts";
|
||||||
|
export { stripBrowserTypes } from "./browser-transpile.ts";
|
||||||
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
||||||
export { generateDeclarations } from "./type-codegen.ts";
|
export { generateDeclarations } from "./type-codegen.ts";
|
||||||
export { generateStoreBrowserModule, generateStoreModule } from "./store-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 { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
|
||||||
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } 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,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("Visible");
|
||||||
expect(output).toContain("Hidden");
|
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", () => {
|
test("component array props support each blocks", () => {
|
||||||
const output = generate(
|
const output = generate(
|
||||||
parse(`
|
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",
|
"name": "@wrnexus/content",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.19",
|
"version": "0.8.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
|
|||||||
var emitPinInputEvent = bridge.emitPinInputEvent;
|
var emitPinInputEvent = bridge.emitPinInputEvent;
|
||||||
var parseScopeDecl = bridge.parseScopeDecl;
|
var parseScopeDecl = bridge.parseScopeDecl;
|
||||||
var warnOnce = bridge.warn || function () {};
|
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}
|
${sections}
|
||||||
function hydrate(root) {
|
function hydrate(root) {
|
||||||
var host = root || document;
|
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.
|
* Run explicit component cleanup before removing the existing page.
|
||||||
*
|
*
|
||||||
@@ -516,6 +553,8 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
|
|
||||||
syncPreservationPolicy(doc);
|
syncPreservationPolicy(doc);
|
||||||
|
|
||||||
|
syncI18n(doc);
|
||||||
|
|
||||||
syncWrnStyles(doc);
|
syncWrnStyles(doc);
|
||||||
|
|
||||||
var importedNodes = [];
|
var importedNodes = [];
|
||||||
@@ -568,6 +607,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
*/
|
*/
|
||||||
rehydrate(currentApp);
|
rehydrate(currentApp);
|
||||||
|
|
||||||
|
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
|
||||||
|
window.__wrnLang.bind(currentApp);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPop) {
|
if (!isPop) {
|
||||||
history.pushState(
|
history.pushState(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var behaviorObserver;
|
var behaviorObserver;
|
||||||
var clientModuleCache = new Map();
|
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
|
* 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
|
* would let any expression reach every global on the page (and would make a
|
||||||
* typo silently resolve to some unrelated window property) -- these lists
|
* typo silently resolve to some unrelated window property) -- these lists
|
||||||
* say exactly what client code may reach.
|
* say exactly what client code may reach.
|
||||||
*
|
*
|
||||||
* dialogGlobals must be bound to window or the browser throws
|
* Prototype-less so a name like "toString" or "constructor" is a miss
|
||||||
* "Illegal invocation" when they are called detached.
|
* rather than a hit on Object.prototype.
|
||||||
*/
|
*/
|
||||||
var dialogGlobals = {
|
function nameSet(names) {
|
||||||
alert: 1,
|
var set = Object.create(null);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Language builtins. Wrapped in thunks so referencing one that a given
|
names.split(" ").forEach(function (name) {
|
||||||
// engine lacks cannot throw at table-definition time.
|
set[name] = 1;
|
||||||
var jsGlobals = {
|
});
|
||||||
Object: function () { return Object; },
|
|
||||||
Boolean: function () { return Boolean; },
|
return set;
|
||||||
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; },
|
* Called with window as the receiver. Detached, the browser throws
|
||||||
Error: function () { return Error; },
|
* "Illegal invocation" for these.
|
||||||
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
|
*/
|
||||||
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
|
var boundWindowGlobals = nameSet(
|
||||||
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
|
"alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
|
||||||
parseInt: function () { return parseInt; },
|
" getComputedStyle structuredClone queueMicrotask btoa atob" +
|
||||||
parseFloat: function () { return parseFloat; },
|
" setTimeout clearTimeout setInterval clearInterval" +
|
||||||
isNaN: function () { return isNaN; },
|
" requestAnimationFrame cancelAnimationFrame",
|
||||||
isFinite: function () { return isFinite; },
|
);
|
||||||
encodeURIComponent: function () { return encodeURIComponent; },
|
|
||||||
decodeURIComponent: function () { return decodeURIComponent; },
|
/*
|
||||||
encodeURI: function () { return encodeURI; },
|
* Language builtins and other realm globals, read off globalThis. Naming
|
||||||
decodeURI: function () { return decodeURI; },
|
* them rather than referencing them directly means one an engine lacks
|
||||||
NaN: function () { return NaN; },
|
* resolves to undefined instead of throwing where the table is defined.
|
||||||
Infinity: function () { return Infinity; },
|
*/
|
||||||
undefined: function () { return undefined; },
|
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.
|
* toast(...) -- raise a notification from any client expression.
|
||||||
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (!window.toast) window.toast = toastApi;
|
if (!window.toast) window.toast = toastApi;
|
||||||
|
|
||||||
// Read straight off window, no binding needed (objects, not functions).
|
// Read straight off window, no binding needed (objects, not functions).
|
||||||
var windowGlobals = {
|
var windowGlobals = nameSet(
|
||||||
localStorage: 1,
|
"localStorage sessionStorage screen performance crypto CustomEvent Event" +
|
||||||
sessionStorage: 1,
|
" FormData URLSearchParams AbortController Notification" +
|
||||||
screen: 1,
|
" IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
|
||||||
performance: 1,
|
" innerWidth innerHeight scrollX scrollY location history navigator",
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reportDiagnostic(code, message, element, detail) {
|
function reportDiagnostic(code, message, element, detail) {
|
||||||
var payload = {
|
var payload = {
|
||||||
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var serverProxy = new Proxy({}, {
|
var serverProxy = new Proxy({}, {
|
||||||
get: function (_target, property) {
|
get: function (_target, property) {
|
||||||
return function () {
|
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 === "server") return serverProxy;
|
||||||
if (name === "props") return propsProxy;
|
if (name === "props") return propsProxy;
|
||||||
if (name === "refs") return refsProxy;
|
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") {
|
if (name === "$emit") {
|
||||||
return function (eventName, detail) {
|
return function (eventName, detail) {
|
||||||
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
||||||
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "window") return window;
|
if (name === "window") return window;
|
||||||
if (name === "document") return document;
|
if (name === "document") return document;
|
||||||
if (name === "console") return console;
|
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 (name === "$route" || name === "route") {
|
||||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||||
return routeValue.get();
|
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.
|
* Ordinary browser and language globals.
|
||||||
*
|
*
|
||||||
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
* lacks one of these does not break the rest.
|
* lacks one of these does not break the rest.
|
||||||
*/
|
*/
|
||||||
if (name === "toast") return toastApi;
|
if (name === "toast") return toastApi;
|
||||||
if (dialogGlobals[name] && typeof window[name] === "function") {
|
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
||||||
return window[name].bind(window);
|
return window[name].bind(window);
|
||||||
}
|
}
|
||||||
if (jsGlobals[name]) {
|
if (ambientGlobals[name]) {
|
||||||
var builtin = jsGlobals[name]();
|
var builtin = globalThis[name];
|
||||||
if (builtin !== undefined) return builtin;
|
if (builtin !== undefined) return builtin;
|
||||||
}
|
}
|
||||||
if (windowGlobals[name]) {
|
if (windowGlobals[name]) {
|
||||||
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readScope(name) {
|
function readScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
if (computing.has(name)) {
|
if (computing.has(name)) {
|
||||||
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (currentRenderer) sig.subscribe(currentRenderer);
|
if (currentRenderer) sig.subscribe(currentRenderer);
|
||||||
return sig.get();
|
return sig.get();
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function peekScope(name) {
|
function peekScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
}
|
}
|
||||||
if (signals[name]) return signals[name].get();
|
if (signals[name]) return signals[name].get();
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function evalExpr(expr, locals) {
|
function evalExpr(expr, locals) {
|
||||||
return evaluateExpression(expr, function (name) {
|
return evaluateExpression(expr, function (name) {
|
||||||
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
|
if (locals && hasOwn(locals, name)) {
|
||||||
return locals[name];
|
return locals[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
source,
|
source,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
|
locals = locals || Object.create(null);
|
||||||
|
|
||||||
return batchUpdates(function () {
|
return batchUpdates(function () {
|
||||||
var statements =
|
var statements =
|
||||||
splitStatements(source);
|
splitStatements(source);
|
||||||
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name) {
|
function (name) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name, value) {
|
function (name, value) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals,
|
locals,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
function (name, value) {
|
||||||
|
locals[name] = value;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.returned) {
|
if (result.returned) {
|
||||||
@@ -1501,19 +1493,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
return type + ":" + String(value);
|
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(
|
function hydrateItem(
|
||||||
root,
|
root,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
function localRead(name) {
|
function localRead(name) {
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(
|
hasOwn(
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1597,7 +1583,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (node !== root && insideNestedLoop(node)) return;
|
if (node !== root && insideNestedLoop(node)) return;
|
||||||
|
|
||||||
var attributes =
|
var attributes =
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
node.attributes,
|
node.attributes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1611,24 +1597,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (
|
if (
|
||||||
attribute.name === "data-text"
|
attribute.name === "data-text"
|
||||||
) {
|
) {
|
||||||
|
(function (textNode, textExpression) {
|
||||||
|
var runText = reactive(function () {
|
||||||
try {
|
try {
|
||||||
var textValue =
|
var textValue = itemEval(textExpression);
|
||||||
itemEval(attribute.value);
|
textNode.textContent = textValue == null ? "" : String(textValue);
|
||||||
|
|
||||||
node.textContent =
|
|
||||||
textValue == null
|
|
||||||
? ""
|
|
||||||
: String(textValue);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[wrnexus] data-for text binding failed for '" +
|
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
|
||||||
attribute.value +
|
|
||||||
"'",
|
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
textNode.textContent = "";
|
||||||
node.textContent = "";
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
runText();
|
||||||
|
})(node, attribute.value);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1642,28 +1625,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
"data-wrn-class-",
|
"data-wrn-class-",
|
||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
var classBinding;
|
var classBinding = pairBinding(attribute.value);
|
||||||
|
|
||||||
try {
|
if (!classBinding) return;
|
||||||
classBinding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
var className = classBinding[0];
|
||||||
!classBinding ||
|
var classExpression = classBinding[1];
|
||||||
classBinding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className =
|
|
||||||
classBinding[0];
|
|
||||||
|
|
||||||
var classExpression =
|
|
||||||
classBinding[1];
|
|
||||||
|
|
||||||
var classEnabled = false;
|
var classEnabled = false;
|
||||||
|
|
||||||
@@ -1693,28 +1660,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
node.removeAttribute(attribute.name);
|
node.removeAttribute(attribute.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(attribute.value);
|
||||||
binding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attributeName =
|
var attributeName = binding[0];
|
||||||
binding[0];
|
var attributeTemplate = binding[1];
|
||||||
|
|
||||||
var attributeTemplate =
|
|
||||||
binding[1];
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Reactive, not resolved once. The expression can read component
|
* Reactive, not resolved once. The expression can read component
|
||||||
@@ -1782,7 +1734,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
eventLocals.event = event;
|
eventLocals.event = event;
|
||||||
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 {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -1934,8 +1886,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
// Hand every nested loop its own renderer, with this item in scope.
|
// Hand every nested loop its own renderer, with this item in scope.
|
||||||
if (root.querySelectorAll) {
|
if (root.querySelectorAll) {
|
||||||
Array.prototype.slice
|
toArray(root.querySelectorAll("[data-for]"))
|
||||||
.call(root.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (nested) {
|
.forEach(function (nested) {
|
||||||
// Only the outermost nested templates: deeper ones are set up by
|
// Only the outermost nested templates: deeper ones are set up by
|
||||||
// their own parent when it renders.
|
// their own parent when it renders.
|
||||||
@@ -1954,6 +1905,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
|
* 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
|
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
||||||
@@ -1981,7 +2081,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopRead(name) {
|
function loopRead(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
|
if (hasOwn(inherited, name)) {
|
||||||
return inherited[name];
|
return inherited[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -2154,7 +2254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
rawKey = evaluateExpression(
|
rawKey = evaluateExpression(
|
||||||
keyExpression,
|
keyExpression,
|
||||||
function (name) {
|
function (name) {
|
||||||
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
return hasOwn(keyedLocals, name)
|
||||||
? keyedLocals[name]
|
? keyedLocals[name]
|
||||||
: loopRead(name);
|
: loopRead(name);
|
||||||
},
|
},
|
||||||
@@ -2227,8 +2327,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-for]"))
|
||||||
.call(el.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (tpl) {
|
.forEach(function (tpl) {
|
||||||
// Only top-level templates here; nested ones are connected by the item
|
// Only top-level templates here; nested ones are connected by the item
|
||||||
// that contains them, once it has values to give them.
|
// that contains them, once it has values to give them.
|
||||||
@@ -2324,24 +2423,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Conditional class bindings emitted as:
|
// Conditional class bindings emitted as:
|
||||||
// data-wrn-class-*='["class-name","expression"]'
|
// data-wrn-class-*='["class-name","expression"]'
|
||||||
var classBindNodes = [el].concat(
|
var classBindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(el.querySelectorAll("*")),
|
toArray(el.querySelectorAll("*")),
|
||||||
);
|
);
|
||||||
|
|
||||||
classBindNodes.forEach(function (node) {
|
classBindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
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;
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
||||||
|
|
||||||
var binding;
|
var binding = pairBinding(marker.value);
|
||||||
|
|
||||||
try {
|
if (!binding) return;
|
||||||
binding = JSON.parse(marker.value);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
|
|
||||||
var className = binding[0];
|
var className = binding[0];
|
||||||
var expression = binding[1];
|
var expression = binding[1];
|
||||||
@@ -2370,7 +2463,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
||||||
// state changes to update type, aria-*, class, href, and other attributes.
|
// state changes to update type, aria-*, class, href, and other attributes.
|
||||||
var bindNodes = [el].concat(
|
var bindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
el.querySelectorAll("*"),
|
el.querySelectorAll("*"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2378,8 +2471,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
bindNodes.forEach(function (node) {
|
bindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(node.attributes)
|
||||||
.call(node.attributes)
|
|
||||||
.forEach(function (marker) {
|
.forEach(function (marker) {
|
||||||
if (
|
if (
|
||||||
marker.name.indexOf(
|
marker.name.indexOf(
|
||||||
@@ -2390,22 +2482,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
node.removeAttribute(marker.name);
|
node.removeAttribute(marker.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(marker.value);
|
||||||
binding = JSON.parse(
|
|
||||||
marker.value,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = binding[0];
|
var name = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
@@ -2497,10 +2577,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event handlers on elements, window, and document.
|
// 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) {
|
nodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
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;
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||||
|
|
||||||
var rawName = attr.name.slice("data-on-".length);
|
var rawName = attr.name.slice("data-on-".length);
|
||||||
@@ -2527,7 +2607,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
locals.event = event;
|
locals.event = event;
|
||||||
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 {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -2576,8 +2656,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
// 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
|
// two cases stay distinguishable, and this scope claims every one that
|
||||||
// sits on a component it directly mounts.
|
// sits on a component it directly mounts.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-wrn-events]"))
|
||||||
.call(el.querySelectorAll("[data-wrn-events]"))
|
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
var componentRoot = closestScope(node);
|
var componentRoot = closestScope(node);
|
||||||
if (!componentRoot || componentRoot === el) return;
|
if (!componentRoot || componentRoot === el) return;
|
||||||
@@ -2594,7 +2673,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
node.__wrnexusOutputHandlers ||
|
node.__wrnexusOutputHandlers ||
|
||||||
(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;
|
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
||||||
|
|
||||||
var outName = attr.name.slice("data-wrn-out-".length);
|
var outName = attr.name.slice("data-wrn-out-".length);
|
||||||
@@ -2632,7 +2711,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload =
|
locals.payload =
|
||||||
event && Object.prototype.hasOwnProperty.call(event, "detail")
|
event && hasOwn(event, "detail")
|
||||||
? event.detail
|
? event.detail
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
@@ -2654,16 +2733,14 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Prop expressions belong to the parent that mounted the component. The
|
// Prop expressions belong to the parent that mounted the component. The
|
||||||
// server forwards these markers onto the rendered child root; evaluate
|
// server forwards these markers onto the rendered child root; evaluate
|
||||||
// them here and write changes into the child's prop signals.
|
// them here and write changes into the child's prop signals.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("*"))
|
||||||
.call(el.querySelectorAll("*"))
|
|
||||||
.filter(isScopeRoot)
|
.filter(isScopeRoot)
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
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;
|
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
||||||
var binding;
|
var binding = pairBinding(attr.value);
|
||||||
try { binding = JSON.parse(attr.value); } catch (_) { return; }
|
if (!binding) return;
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
var propName = binding[0];
|
var propName = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
reactive(function () {
|
reactive(function () {
|
||||||
@@ -3017,8 +3094,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var host = root && root.querySelectorAll ? root : document;
|
var host = root && root.querySelectorAll ? root : document;
|
||||||
anchoredWriting = true;
|
anchoredWriting = true;
|
||||||
try {
|
try {
|
||||||
Array.prototype.slice
|
toArray(host.querySelectorAll(ANCHORED_SELECTOR))
|
||||||
.call(host.querySelectorAll(ANCHORED_SELECTOR))
|
|
||||||
.forEach(clampAnchored);
|
.forEach(clampAnchored);
|
||||||
} finally {
|
} finally {
|
||||||
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
||||||
@@ -3552,8 +3628,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// therefore no client-side binding to retain; consume its compiler markers
|
// therefore no client-side binding to retain; consume its compiler markers
|
||||||
// separately from component hydration.
|
// separately from component hydration.
|
||||||
if (host === document || host === document.documentElement) {
|
if (host === document || host === document.documentElement) {
|
||||||
Array.prototype.slice
|
toArray(document.documentElement.attributes)
|
||||||
.call(document.documentElement.attributes)
|
|
||||||
.forEach(function (attribute) {
|
.forEach(function (attribute) {
|
||||||
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
||||||
document.documentElement.removeAttribute(attribute.name);
|
document.documentElement.removeAttribute(attribute.name);
|
||||||
@@ -4195,7 +4270,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function emitPinInputEvent(root, name, extra) {
|
function emitPinInputEvent(root, name, extra) {
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
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 value = hidden ? hidden.value : "";
|
||||||
var detail = {
|
var detail = {
|
||||||
component: "PinInput",
|
component: "PinInput",
|
||||||
@@ -4217,7 +4292,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
||||||
function setupPinInputController(root) {
|
function setupPinInputController(root) {
|
||||||
if (!root || root.__wrnexusPinInputController) return;
|
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 hidden = root.querySelector("[data-pin-value]");
|
||||||
var clearButton = root.querySelector("[data-pin-clear]");
|
var clearButton = root.querySelector("[data-pin-clear]");
|
||||||
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
||||||
@@ -4651,12 +4726,68 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
: null;
|
: 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(
|
function runStatement(
|
||||||
stmt,
|
stmt,
|
||||||
evalExpr,
|
evalExpr,
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
runBlock,
|
runBlock,
|
||||||
|
declare,
|
||||||
) {
|
) {
|
||||||
stmt = String(stmt || "").trim();
|
stmt = String(stmt || "").trim();
|
||||||
|
|
||||||
@@ -4707,6 +4838,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(
|
var increment = stmt.match(
|
||||||
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
||||||
);
|
);
|
||||||
@@ -5699,7 +5884,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
||||||
if (element.__wrnDynamicMounted) return;
|
if (element.__wrnDynamicMounted) return;
|
||||||
element.__wrnDynamicMounted = true;
|
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");
|
return candidate.hasAttribute("data-component-case");
|
||||||
}).map(function (candidate) {
|
}).map(function (candidate) {
|
||||||
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
||||||
|
|||||||
@@ -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 { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
let win: any;
|
let win: any;
|
||||||
let fetchCalls: { url: string; opts: any }[];
|
let fetchCalls: { url: string; opts: any }[];
|
||||||
@@ -36,9 +37,7 @@ function install(bodyHtml: string): void {
|
|||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
beforeEach(() => {
|
const REPLACED_GLOBALS = [
|
||||||
const g = globalThis as any;
|
|
||||||
for (const k of [
|
|
||||||
"window",
|
"window",
|
||||||
"document",
|
"document",
|
||||||
"history",
|
"history",
|
||||||
@@ -47,7 +46,13 @@ beforeEach(() => {
|
|||||||
"CustomEvent",
|
"CustomEvent",
|
||||||
"Event",
|
"Event",
|
||||||
"fetch",
|
"fetch",
|
||||||
]) {
|
];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const g = globalThis as any;
|
||||||
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete g[k];
|
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();
|
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 () => {
|
test("unmounts and remounts package runtimes during client navigation", async () => {
|
||||||
install(
|
install(
|
||||||
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
|
`<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 { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
||||||
import { mountHtml } from "@wrnexus/test";
|
import { mountHtml } from "@wrnexus/test";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
// Fresh DOM per test, with the runtime's globals bound.
|
// Fresh DOM per test, with the runtime's globals bound.
|
||||||
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
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;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete (globalThis as Record<string, unknown>).window;
|
for (const name of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>).document;
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
delete (globalThis as Record<string, unknown>).location;
|
}
|
||||||
delete (globalThis as Record<string, unknown>).fetch;
|
|
||||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("split runtime hydrates a controller only from the controller asset", () => {
|
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");
|
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", () => {
|
test("component functions support formatted multiline assignments and ternaries", () => {
|
||||||
const behavior = Buffer.from(
|
const behavior = Buffer.from(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -1582,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
|
|||||||
);
|
);
|
||||||
expect(seen).toEqual([60]);
|
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 { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
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. */
|
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||||
let sockets: FakeWS[];
|
let sockets: FakeWS[];
|
||||||
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>)[k];
|
delete (globalThis as Record<string, unknown>)[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.12",
|
"version": "0.8.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@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";
|
const DEFAULT = "default";
|
||||||
type DbFactory = () => Db;
|
type DbFactory = () => Db;
|
||||||
type RegistryEntry = { db?: Db; factory?: DbFactory };
|
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). */
|
/** Set the default database (called by the runtime at startup). */
|
||||||
export function setDb(db: Db): Db;
|
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 {
|
export function setDb(a: string | Db, b?: Db): Db {
|
||||||
const name = typeof a === "string" ? a : DEFAULT;
|
const name = typeof a === "string" ? a : DEFAULT;
|
||||||
const db = typeof a === "string" ? b! : a;
|
const db = typeof a === "string" ? b! : a;
|
||||||
registry.set(name, { db });
|
databaseRegistry().set(name, { db });
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +48,12 @@ export function registerDb(name: string, db: Db): Db {
|
|||||||
* `getDb(name)` call creates and caches the connection.
|
* `getDb(name)` call creates and caches the connection.
|
||||||
*/
|
*/
|
||||||
export function registerLazyDb(name: string, factory: DbFactory): void {
|
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. */
|
/** The default database, or a named one. Throws if it isn't configured. */
|
||||||
export function getDb(name = DEFAULT): Db {
|
export function getDb(name = DEFAULT): Db {
|
||||||
const entry = registry.get(name);
|
const entry = databaseRegistry().get(name);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
name === DEFAULT
|
name === DEFAULT
|
||||||
@@ -60,16 +71,17 @@ export function getDb(name = DEFAULT): Db {
|
|||||||
|
|
||||||
/** Whether the default (or a named) database has been configured. */
|
/** Whether the default (or a named) database has been configured. */
|
||||||
export function hasDb(name = DEFAULT): boolean {
|
export function hasDb(name = DEFAULT): boolean {
|
||||||
return registry.has(name);
|
return databaseRegistry().has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Names of all configured databases (the default appears as "default"). */
|
/** Names of all configured databases (the default appears as "default"). */
|
||||||
export function databaseNames(): string[] {
|
export function databaseNames(): string[] {
|
||||||
return [...registry.keys()];
|
return [...databaseRegistry().keys()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close every configured database and clear the registry. */
|
/** Close every configured database and clear the registry. */
|
||||||
export async function closeDatabases(): Promise<void> {
|
export async function closeDatabases(): Promise<void> {
|
||||||
|
const registry = databaseRegistry();
|
||||||
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
|
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
|
||||||
registry.clear();
|
registry.clear();
|
||||||
const results = await Promise.allSettled(databases.map((db) => db.close()));
|
const results = await Promise.allSettled(databases.map((db) => db.close()));
|
||||||
|
|||||||
@@ -226,11 +226,16 @@ export function generateQueriesFile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imports = [
|
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
|
||||||
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
|
|
||||||
];
|
|
||||||
if (usedModels.size > 0) {
|
if (usedModels.size > 0) {
|
||||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
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;
|
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. */
|
/** Load and parse all migration files in a directory, sorted by filename. */
|
||||||
export function loadMigrations(dir: string): Migration[] {
|
export function loadMigrations(dir: string): Migration[] {
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
@@ -171,7 +206,12 @@ export async function applyMigrations(
|
|||||||
for (const migration of pending.filter(({ name }) => !current.has(name))) {
|
for (const migration of pending.filter(({ name }) => !current.has(name))) {
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await db.tx(async (tx) => {
|
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]);
|
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||||
});
|
});
|
||||||
done.push(migration.name);
|
done.push(migration.name);
|
||||||
|
|||||||
@@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async (
|
|||||||
await db.close();
|
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 () => {
|
test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
|
||||||
const db = createDb(sqlite());
|
const db = createDb(sqlite());
|
||||||
const migrations = [
|
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(secondClosed).toBe(true);
|
||||||
expect(databaseNames()).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ interface RunningServer {
|
|||||||
|
|
||||||
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
|
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
|
||||||
|
|
||||||
|
### Custom not-found handlers
|
||||||
|
|
||||||
|
Add `app/pages/404.wrn` to customize unmatched frontend routes. The rendered
|
||||||
|
page keeps the requested response's HTTP `404` status. Add `app/api/404.ts`
|
||||||
|
with normal HTTP method exports to customize unmatched backend/API responses;
|
||||||
|
its response body and headers are preserved and its status is normalized to
|
||||||
|
`404`.
|
||||||
|
|
||||||
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
|
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
|
||||||
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
|
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
|
||||||
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
|
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.29",
|
"version": "0.8.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type ResolvedTheme,
|
type ResolvedTheme,
|
||||||
type StylesConfig,
|
type StylesConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
|
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||||
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
|
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
|
||||||
import { I18N_RUNTIME } from "@wrnexus/i18n";
|
import { I18N_RUNTIME } from "@wrnexus/i18n";
|
||||||
import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader";
|
import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader";
|
||||||
@@ -32,7 +33,8 @@ import type { Mode } from "@wrnexus/core";
|
|||||||
import type { AssetServer } from "./runtime.ts";
|
import type { AssetServer } from "./runtime.ts";
|
||||||
import { servePublicAsset } from "./public.ts";
|
import { servePublicAsset } from "./public.ts";
|
||||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||||
import { serveWrnBrowserArtifact } from "./pipeline.ts";
|
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||||
|
import { HMR_CLIENT_HREF, HMR_CLIENT_JS } from "./runtime.ts";
|
||||||
|
|
||||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||||
export interface DevStyles {
|
export interface DevStyles {
|
||||||
@@ -95,6 +97,11 @@ export function createDevAssetServer(
|
|||||||
if (pathname.startsWith("/__wrnexus/client/")) {
|
if (pathname.startsWith("/__wrnexus/client/")) {
|
||||||
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
|
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
if (pathname === HMR_CLIENT_HREF) return jsResponse(HMR_CLIENT_JS);
|
||||||
|
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
||||||
if (pathname === "/__wrnexus/controllers.js")
|
if (pathname === "/__wrnexus/controllers.js")
|
||||||
return jsResponse(getComponentControllerRuntime(true));
|
return jsResponse(getComponentControllerRuntime(true));
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
|
|||||||
interface WsBridge {
|
interface WsBridge {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
backend?: WebSocket;
|
backend?: WebSocket;
|
||||||
queue: Array<string | ArrayBuffer>;
|
queue: Array<string | ArrayBuffer>;
|
||||||
maxMessageBytes: number;
|
maxMessageBytes: number;
|
||||||
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
|||||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayWebSocketOriginAllowed(
|
export function gatewayWebSocketOriginAllowed(
|
||||||
req: Request,
|
req: Request,
|
||||||
target: Target,
|
target: Target,
|
||||||
configured: string[],
|
configured: string[],
|
||||||
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
|||||||
}
|
}
|
||||||
if (configured.includes(origin)) return true;
|
if (configured.includes(origin)) return true;
|
||||||
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
||||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
// Compare hostnames, not hosts: configured domains carry no port, while the
|
||||||
|
// browser's Origin does. publicOrigin above only ever matches domains[0], so
|
||||||
|
// every other domain fell through to here and was denied purely on the port,
|
||||||
|
// which left the HMR socket reconnecting forever on those hosts.
|
||||||
|
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,6 +438,30 @@ export function gatewayProxyHeaders(
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward application identity to the child while leaving WebSocket framing to Bun. */
|
||||||
|
export function gatewayWebSocketBackendHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
headers.delete("upgrade");
|
||||||
|
headers.delete("accept-encoding");
|
||||||
|
for (const name of [...headers.keys()]) {
|
||||||
|
if (name.startsWith("sec-websocket-")) headers.delete(name);
|
||||||
|
}
|
||||||
|
// The public origin was validated at the gateway edge. The child receives a
|
||||||
|
// new, trusted same-origin connection from its private gateway listener.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
return Object.fromEntries(headers);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove headers that only a direct workspace-to-app request may supply. */
|
/** Remove headers that only a direct workspace-to-app request may supply. */
|
||||||
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||||
const sanitized = new Headers(headers);
|
const sanitized = new Headers(headers);
|
||||||
@@ -441,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reserved inter-app RPC namespace is refused at the gateway edge, before
|
* Private inter-app RPC routes are refused at the gateway edge. The exact
|
||||||
* any proxying — it is only ever mounted by a child app's own dev-server and
|
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
|
||||||
* must never be reachable from outside the workspace.
|
* intentionally proxied to the selected child app.
|
||||||
*/
|
*/
|
||||||
export function isRpcGatewayPath(pathname: string): boolean {
|
export function isRpcGatewayPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === RPC_PATH_PREFIX ||
|
|
||||||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
||||||
pathname === RPC_STREAM_PATH_PREFIX ||
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
||||||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the trusted internal hop for a browser server-function request. */
|
||||||
|
export function gatewayBrowserRpcHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Headers {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
// The public request already passed the gateway's host and fetch-metadata
|
||||||
|
// checks. Present the internal proxy hop as same-origin to the child while
|
||||||
|
// retaining the double-submit CSRF cookie and header.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
headers.delete("host");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot every app as a child process, then route by Host on one gateway port. */
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
||||||
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
||||||
const port = opts.port ?? 3000;
|
const port = opts.port ?? 3000;
|
||||||
@@ -468,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
);
|
);
|
||||||
// Loopback-only origins, computed up front (ports are assigned by index
|
// Loopback-only origins, computed up front (ports are assigned by index
|
||||||
// before any child spawns) so every child can reach every other child
|
// before any child spawns) so every child can reach every other child
|
||||||
// directly — bypassing the gateway, which 404s the RPC prefix by design.
|
// directly — bypassing the gateway, which 404s private nested RPC routes.
|
||||||
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
||||||
@@ -692,6 +739,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
data: {
|
data: {
|
||||||
origin: target.origin,
|
origin: target.origin,
|
||||||
path: url.pathname + url.search,
|
path: url.pathname + url.search,
|
||||||
|
headers: gatewayWebSocketBackendHeaders(
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
ip,
|
||||||
|
forwardedHeaders,
|
||||||
|
target.origin,
|
||||||
|
),
|
||||||
queue: [],
|
queue: [],
|
||||||
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
||||||
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
||||||
@@ -701,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
||||||
const headers = stripUntrustedInternalHeaders(
|
const headers =
|
||||||
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
url.pathname === RPC_PATH_PREFIX
|
||||||
);
|
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
|
||||||
|
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
|
||||||
const body =
|
const body =
|
||||||
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -753,7 +808,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
websocket: {
|
websocket: {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
||||||
const backend = new WebSocket(backendUrl);
|
const BackendWebSocket = WebSocket as unknown as new (
|
||||||
|
url: string,
|
||||||
|
options: Bun.WebSocketOptions,
|
||||||
|
) => WebSocket;
|
||||||
|
const backend = new BackendWebSocket(backendUrl, { headers: ws.data.headers });
|
||||||
ws.data.backend = backend;
|
ws.data.backend = backend;
|
||||||
backend.addEventListener("open", () => {
|
backend.addEventListener("open", () => {
|
||||||
for (const m of ws.data.queue) backend.send(m);
|
for (const m of ws.data.queue) backend.send(m);
|
||||||
|
|||||||
@@ -43,10 +43,27 @@ import {
|
|||||||
loadWrnServerModule,
|
loadWrnServerModule,
|
||||||
setCompileCacheDir,
|
setCompileCacheDir,
|
||||||
setCompileImportOptions,
|
setCompileImportOptions,
|
||||||
|
rebuildChangedIslands,
|
||||||
setDevCompilerPipeline,
|
setDevCompilerPipeline,
|
||||||
wrnBrowserArtifactUrlAsync,
|
wrnBrowserArtifactUrlAsync,
|
||||||
} from "./pipeline.ts";
|
} from "./pipeline.ts";
|
||||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||||
|
import { createRecycleMonitor } from "./recycle.ts";
|
||||||
|
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
|
||||||
|
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
|
||||||
|
* session reaches, and far less than what makes the server crawl. Set
|
||||||
|
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
|
||||||
|
*/
|
||||||
|
const RECYCLE_REBUILD_THRESHOLD = (() => {
|
||||||
|
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
|
||||||
|
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
|
||||||
|
})();
|
||||||
|
/** Quiet period required first, so a recycle never interrupts a live request. */
|
||||||
|
const RECYCLE_IDLE_MS = 10_000;
|
||||||
|
const RECYCLE_CHECK_MS = 5_000;
|
||||||
import { createHandlers, type WsData } from "./runtime.ts";
|
import { createHandlers, type WsData } from "./runtime.ts";
|
||||||
import { createDevAssetServer } from "./assets.ts";
|
import { createDevAssetServer } from "./assets.ts";
|
||||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||||
@@ -439,10 +456,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
config: opts.stylesConfig,
|
config: opts.stylesConfig,
|
||||||
appRoot,
|
appRoot,
|
||||||
publicDir: join(appRoot, "public"),
|
publicDir: join(appRoot, "public"),
|
||||||
sources: [
|
// Component discovery is separate from utility-source discovery.
|
||||||
...componentDirs,
|
// Packages that need Tailwind scanning opt in via styles.source.
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||||
],
|
|
||||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
theme,
|
theme,
|
||||||
@@ -588,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
validateCsrf: validateRpcCsrf,
|
validateCsrf: validateRpcCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
|
||||||
|
* modules, so only dev needs to recycle.
|
||||||
|
*/
|
||||||
|
const recycle =
|
||||||
|
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
|
||||||
|
? createRecycleMonitor({
|
||||||
|
threshold: RECYCLE_REBUILD_THRESHOLD,
|
||||||
|
idleMs: RECYCLE_IDLE_MS,
|
||||||
|
onRecycle(reason) {
|
||||||
|
console.log(`[wrnexus] ${reason}`);
|
||||||
|
process.exit(RESTART_EXIT_CODE);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const server = Bun.serve<WsData>({
|
const server = Bun.serve<WsData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -595,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
maxRequestBodySize: 10 * 1024 * 1024,
|
maxRequestBodySize: 10 * 1024 * 1024,
|
||||||
...(opts.tls ? { tls: opts.tls } : {}),
|
...(opts.tls ? { tls: opts.tls } : {}),
|
||||||
fetch(request, server) {
|
fetch(request, server) {
|
||||||
|
recycle?.recordRequest(Date.now());
|
||||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||||
return handlers.fetch(request, server);
|
return handlers.fetch(request, server);
|
||||||
},
|
},
|
||||||
websocket: handlers.websocket,
|
websocket: handlers.websocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recycle) {
|
||||||
|
// unref so a pending check never keeps the process alive on its own.
|
||||||
|
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pluginRunner.hook("configureServer", {
|
await pluginRunner.hook("configureServer", {
|
||||||
server,
|
server,
|
||||||
@@ -626,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
||||||
|
recycle?.recordRebuild();
|
||||||
}
|
}
|
||||||
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
||||||
|
|
||||||
@@ -685,6 +725,18 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||||
await pluginRunner.hook("hmrUpdate", files);
|
await pluginRunner.hook("hmrUpdate", files);
|
||||||
|
// Island .tsx sources are not .wrn files, so nothing below would rebuild
|
||||||
|
// them; page modules are cached, so no compile runs on the next request.
|
||||||
|
const islandFiles = files
|
||||||
|
.map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed)))
|
||||||
|
.filter((changed) => changed.endsWith(".tsx"));
|
||||||
|
if (islandFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
await rebuildChangedIslands(islandFiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[wrnexus] island rebuild failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
|
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
|
||||||
for (const changed of files) {
|
for (const changed of files) {
|
||||||
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
compile,
|
compile,
|
||||||
generate,
|
generate,
|
||||||
generateTargets,
|
generateTargets,
|
||||||
|
buildIslands,
|
||||||
|
islandNamesFrom,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
type PageAst,
|
type PageAst,
|
||||||
type ViewNode,
|
type ViewNode,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
@@ -56,7 +59,27 @@ export function runMiddleware(
|
|||||||
*/
|
*/
|
||||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||||
const moduleVersions = new Map<string, number>();
|
const moduleVersions = new Map<string, number>();
|
||||||
|
/*
|
||||||
|
* Artifact URLs carry a content hash, so a rebuild registers a new key and the
|
||||||
|
* previous one is never requested again -- left alone these grow for the life
|
||||||
|
* of the dev server. Bounded rather than cleared on rebuild because a page
|
||||||
|
* already mid-load may still ask for the URL it was served.
|
||||||
|
*/
|
||||||
|
const ARTIFACT_PATH_LIMIT = 512;
|
||||||
const browserArtifactPaths = new Map<string, string>();
|
const browserArtifactPaths = new Map<string, string>();
|
||||||
|
const islandArtifactPaths = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberArtifact(paths: Map<string, string>, pathname: string, artifact: string): void {
|
||||||
|
// Re-insert so a key still in use is treated as recent.
|
||||||
|
paths.delete(pathname);
|
||||||
|
paths.set(pathname, artifact);
|
||||||
|
|
||||||
|
while (paths.size > ARTIFACT_PATH_LIMIT) {
|
||||||
|
const oldest = paths.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
paths.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||||
interface CompileImportOptions {
|
interface CompileImportOptions {
|
||||||
@@ -125,6 +148,102 @@ function importOptionsHash(file: string): string {
|
|||||||
return hashPath(JSON.stringify({ ...options, aliases }));
|
return hashPath(JSON.stringify({ ...options, aliases }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Island bundles already built this dev session, keyed by resolved source. */
|
||||||
|
const builtIslands = new Map<string, string>();
|
||||||
|
/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */
|
||||||
|
const islandSources = new Map<string, string>();
|
||||||
|
let islandAppRoot: string | null = null;
|
||||||
|
let islandRuntimeBuilt = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds island bundles on demand in dev and registers them under
|
||||||
|
* /__wrnexus/island/. Without this the browser bootstrap 404s and no island
|
||||||
|
* ever mounts.
|
||||||
|
*/
|
||||||
|
async function ensureIslandArtifacts(
|
||||||
|
islands: Array<{ name: string; sourcePath: string }>,
|
||||||
|
appRoot: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (islands.length === 0) return;
|
||||||
|
const outDir = join(appRoot, ".wrnexus", "island");
|
||||||
|
islandAppRoot = appRoot;
|
||||||
|
for (const island of islands) islandSources.set(island.name, island.sourcePath);
|
||||||
|
|
||||||
|
// Keyed by source AND mtime: keying on the path alone serves a stale bundle
|
||||||
|
// forever once an island .tsx is edited.
|
||||||
|
const stamp = (island: { name: string; sourcePath: string }) => {
|
||||||
|
let mtime: number;
|
||||||
|
try {
|
||||||
|
mtime = statSync(island.sourcePath).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
mtime = 0;
|
||||||
|
}
|
||||||
|
return `${island.sourcePath}:${mtime}`;
|
||||||
|
};
|
||||||
|
const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island));
|
||||||
|
if (pending.length === 0 && islandRuntimeBuilt) return;
|
||||||
|
|
||||||
|
// The runtime is built alongside the islands so they share one React copy.
|
||||||
|
const result = await buildIslands({
|
||||||
|
islands: pending.length ? pending : islands,
|
||||||
|
outDir,
|
||||||
|
appRoot,
|
||||||
|
});
|
||||||
|
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
|
||||||
|
islandRuntimeBuilt = true;
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
|
||||||
|
const rebuilt = pending.find((island) => island.name === asset.name);
|
||||||
|
if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt));
|
||||||
|
}
|
||||||
|
for (const chunk of result.sharedChunks) {
|
||||||
|
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Island imports in this file: names so codegen emits placeholders instead of
|
||||||
|
* component mounts, and sources so the bundles can be built.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Rebuilds islands whose .tsx source changed.
|
||||||
|
*
|
||||||
|
* Page modules are cached after the first request, so no compile runs on a
|
||||||
|
* later request and nothing else would notice an island edit.
|
||||||
|
*/
|
||||||
|
export async function rebuildChangedIslands(changed: string[]): Promise<boolean> {
|
||||||
|
if (!islandAppRoot) return false;
|
||||||
|
const touched = new Set(changed.map((file) => resolve(file)));
|
||||||
|
const affected = [...islandSources]
|
||||||
|
.filter(([, sourcePath]) => touched.has(resolve(sourcePath)))
|
||||||
|
.map(([name, sourcePath]) => ({ name, sourcePath }));
|
||||||
|
if (affected.length === 0) return false;
|
||||||
|
await ensureIslandArtifacts(affected, islandAppRoot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandsForFile(
|
||||||
|
ast: PageAst,
|
||||||
|
importer: string,
|
||||||
|
): { names: Set<string>; inputs: Array<{ name: string; sourcePath: string }> } {
|
||||||
|
if (!ast.structuredImports.length) return { names: new Set(), inputs: [] };
|
||||||
|
const root = projectRootForFile(importer);
|
||||||
|
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
||||||
|
mode: "compatible" as const,
|
||||||
|
aliases: { "@": "./app" },
|
||||||
|
autoImport: true,
|
||||||
|
};
|
||||||
|
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
||||||
|
appRoot: root,
|
||||||
|
mode: importOptions.mode,
|
||||||
|
aliases: importOptions.aliases,
|
||||||
|
});
|
||||||
|
const inputs = resolved
|
||||||
|
.filter((entry) => entry.kind === "island" && entry.resolved && entry.declaration.defaultImport)
|
||||||
|
.map((entry) => ({ name: entry.declaration.defaultImport!, sourcePath: entry.resolved! }));
|
||||||
|
return { names: islandNamesFrom(resolved), inputs };
|
||||||
|
}
|
||||||
|
|
||||||
function rewriteArtifactImports(
|
function rewriteArtifactImports(
|
||||||
code: string,
|
code: string,
|
||||||
ast: PageAst,
|
ast: PageAst,
|
||||||
@@ -395,6 +514,8 @@ export interface WrnCompileArtifacts {
|
|||||||
declarations: string;
|
declarations: string;
|
||||||
contract: string;
|
contract: string;
|
||||||
rpc: string;
|
rpc: string;
|
||||||
|
/** Island inputs for this file, so a cache hit can still build islands. */
|
||||||
|
islands: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WrnCompileMetrics {
|
export interface WrnCompileMetrics {
|
||||||
@@ -485,15 +606,20 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||||
contract: join(cacheDir, `${stem}.contract.json`),
|
contract: join(cacheDir, `${stem}.contract.json`),
|
||||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||||
|
islands: join(cacheDir, `${stem}.islands.json`),
|
||||||
};
|
};
|
||||||
const result = compile(source, file);
|
const result = compile(source, file);
|
||||||
validateConfiguredImports(source, result.ast, file);
|
validateConfiguredImports(source, result.ast, file);
|
||||||
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
||||||
|
const { names: islands, inputs: islandInputs } = islandsForFile(ast, file);
|
||||||
|
mkdirSync(cacheDir, { recursive: true });
|
||||||
|
writeFileSync(artifacts.islands, JSON.stringify(islandInputs), "utf8");
|
||||||
|
await ensureIslandArtifacts(islandInputs, projectRootForFile(file));
|
||||||
const targets = generateTargets(ast);
|
const targets = generateTargets(ast);
|
||||||
mkdirSync(cacheDir, { recursive: true });
|
mkdirSync(cacheDir, { recursive: true });
|
||||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||||
const outputs = {
|
const outputs = {
|
||||||
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
|
main: `// compiled from .wrn\n${generate(ast, { islands })}`.replaceAll(
|
||||||
"__WRNEXUS_CLIENT_MODULE__",
|
"__WRNEXUS_CLIENT_MODULE__",
|
||||||
browserPath,
|
browserPath,
|
||||||
),
|
),
|
||||||
@@ -505,6 +631,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
||||||
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
||||||
if (target === "browser") {
|
if (target === "browser") {
|
||||||
|
transformed = stripBrowserTypes(transformed);
|
||||||
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
||||||
}
|
}
|
||||||
writeFileSync(artifacts[target], transformed, "utf8");
|
writeFileSync(artifacts[target], transformed, "utf8");
|
||||||
@@ -516,7 +643,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
);
|
);
|
||||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
compileMetrics.compilations++;
|
compileMetrics.compilations++;
|
||||||
return artifacts;
|
return artifacts;
|
||||||
})().finally(() => asyncCompileInProgress.delete(key));
|
})().finally(() => asyncCompileInProgress.delete(key));
|
||||||
@@ -554,13 +681,36 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||||
contract: join(cacheDir, `${stem}.contract.json`),
|
contract: join(cacheDir, `${stem}.contract.json`),
|
||||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||||
|
islands: join(cacheDir, `${stem}.islands.json`),
|
||||||
};
|
};
|
||||||
compileInProgress.set(file, artifacts);
|
compileInProgress.set(file, artifacts);
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
// The islands manifest is written only by the async compile path, so it
|
||||||
|
// is not part of the completeness check — a missing manifest means "no
|
||||||
|
// islands known for this file", not a stale cache.
|
||||||
|
const requiredArtifacts = Object.entries(artifacts)
|
||||||
|
.filter(([key]) => key !== "islands")
|
||||||
|
.map(([, path]) => path);
|
||||||
|
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
||||||
compileMetrics.hits++;
|
compileMetrics.hits++;
|
||||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||||
|
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
||||||
|
// since, and after a restart with a warm cache nothing else would build them.
|
||||||
|
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
||||||
|
try {
|
||||||
|
cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{
|
||||||
|
name: string;
|
||||||
|
sourcePath: string;
|
||||||
|
}>;
|
||||||
|
} catch {
|
||||||
|
cachedIslands = [];
|
||||||
|
}
|
||||||
|
// This variant is synchronous, so the rebuild is kicked off rather than
|
||||||
|
// awaited. The async compile path awaits it before serving a page.
|
||||||
|
void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => {
|
||||||
|
console.warn("[wrnexus] island rebuild failed", error);
|
||||||
|
});
|
||||||
return artifacts;
|
return artifacts;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -583,10 +733,10 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.browser,
|
artifacts.browser,
|
||||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
stripBrowserTypes(rewriteArtifactImports(targets.browser, result.ast, file, "browser")),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.server,
|
artifacts.server,
|
||||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||||
@@ -644,6 +794,25 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
||||||
|
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
||||||
|
rememberArtifact(islandArtifactPaths, pathname, artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
||||||
|
export function serveIslandArtifact(pathname: string): Response | null {
|
||||||
|
const artifact = islandArtifactPaths.get(pathname);
|
||||||
|
if (!artifact || !existsSync(artifact)) return null;
|
||||||
|
return new Response(readFileSync(artifact, "utf8"), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/javascript; charset=utf-8",
|
||||||
|
"cache-control": "no-store, max-age=0",
|
||||||
|
pragma: "no-cache",
|
||||||
|
expires: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
||||||
export function invalidateModule(file: string): void {
|
export function invalidateModule(file: string): void {
|
||||||
file = resolve(file);
|
file = resolve(file);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
getNavRuntime,
|
getNavRuntime,
|
||||||
getRealtimeRuntime,
|
getRealtimeRuntime,
|
||||||
} from "@wrnexus/csr";
|
} from "@wrnexus/csr";
|
||||||
|
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||||
import {
|
import {
|
||||||
loadEnv,
|
loadEnv,
|
||||||
resolveProfile,
|
resolveProfile,
|
||||||
@@ -99,6 +100,7 @@ export interface ProdOptions {
|
|||||||
controllersPath?: string;
|
controllersPath?: string;
|
||||||
/** Absolute directory containing bundled per-WRN browser modules. */
|
/** Absolute directory containing bundled per-WRN browser modules. */
|
||||||
clientModulesDir?: string;
|
clientModulesDir?: string;
|
||||||
|
islandsDir?: string;
|
||||||
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
|
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
|
||||||
themePath?: string;
|
themePath?: string;
|
||||||
/** Pre-built active theme/accent stylesheets, loaded on demand. */
|
/** Pre-built active theme/accent stylesheets, loaded on demand. */
|
||||||
@@ -341,6 +343,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
|
|||||||
}
|
}
|
||||||
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
|
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
|
const name = pathname.slice("/__wrnexus/island/".length);
|
||||||
|
if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
return serveFile(join(opts.islandsDir, name), JS_HEADERS);
|
||||||
|
}
|
||||||
|
if (pathname === "/__wrnexus/islands.js")
|
||||||
|
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
||||||
if (pathname === "/__wrnexus/reactive.js") {
|
if (pathname === "/__wrnexus/reactive.js") {
|
||||||
if (opts.reactivePath) {
|
if (opts.reactivePath) {
|
||||||
const file = Bun.file(opts.reactivePath);
|
const file = Bun.file(opts.reactivePath);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Recycle the dev server once hot rebuilds have piled up.
|
||||||
|
*
|
||||||
|
* Every rebuild of a `.wrn` file has to be given a new module identity,
|
||||||
|
* because Bun caches modules by path and would otherwise serve the old one.
|
||||||
|
* Bun has no API to unload a module, so each rebuild retains its predecessor
|
||||||
|
* for the life of the process -- measured at roughly 0.66 MB per rebuild,
|
||||||
|
* while edits that mint no new module (CSS) cost nothing. Over a long session
|
||||||
|
* that is the difference between a fast dev server and a stuck one.
|
||||||
|
*
|
||||||
|
* The process therefore recycles itself: the child exits with
|
||||||
|
* RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on
|
||||||
|
* their own because the HMR client already retries.
|
||||||
|
*
|
||||||
|
* Recycling is deferred until the server has been idle for a moment, so it
|
||||||
|
* never interrupts a request in flight. The cost is that in-memory state
|
||||||
|
* (realtime rooms, warmed caches) resets at that point, which is why the
|
||||||
|
* threshold is high enough that an ordinary editing session never reaches it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecycleMonitorOptions {
|
||||||
|
/** Retained rebuilds tolerated before a recycle is armed. */
|
||||||
|
threshold: number;
|
||||||
|
/** Quiet period required before recycling, in milliseconds. */
|
||||||
|
idleMs: number;
|
||||||
|
onRecycle: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleMonitor {
|
||||||
|
/** Count one rebuild that retained a module version. */
|
||||||
|
recordRebuild(): void;
|
||||||
|
/** Note that a request was served, at `now`. */
|
||||||
|
recordRequest(now: number): void;
|
||||||
|
/** Recycle if the threshold is passed and the server has gone quiet. */
|
||||||
|
tick(now: number): void;
|
||||||
|
retained(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor {
|
||||||
|
let rebuilds = 0;
|
||||||
|
let lastRequestAt: number | null = null;
|
||||||
|
let recycled = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordRebuild() {
|
||||||
|
rebuilds++;
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRequest(now: number) {
|
||||||
|
lastRequestAt = now;
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(now: number) {
|
||||||
|
if (recycled) return;
|
||||||
|
if (rebuilds < options.threshold) return;
|
||||||
|
// A server that has served nothing is idle by definition.
|
||||||
|
if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return;
|
||||||
|
|
||||||
|
recycled = true;
|
||||||
|
options.onRecycle(
|
||||||
|
`${rebuilds} hot rebuilds retained; restarting to release the memory they hold`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
retained() {
|
||||||
|
return rebuilds;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,6 +672,19 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (i18nScript) {
|
||||||
|
try {
|
||||||
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
|
var existingI18n = window.__wrnI18n || {};
|
||||||
|
incomingI18n.t = existingI18n.t;
|
||||||
|
incomingI18n.set = existingI18n.set;
|
||||||
|
window.__wrnI18n = incomingI18n;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.title) {
|
if (doc.title) {
|
||||||
document.title = doc.title;
|
document.title = doc.title;
|
||||||
}
|
}
|
||||||
@@ -704,6 +717,10 @@ export const HMR_CLIENT_JS = `
|
|||||||
window.__wrnexusHydrateCsrFetches(document);
|
window.__wrnexusHydrateCsrFetches(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
|
||||||
|
window.__wrnLang.bind(document);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.wrnTheme &&
|
window.wrnTheme &&
|
||||||
typeof window.wrnTheme.bind === "function"
|
typeof window.wrnTheme.bind === "function"
|
||||||
@@ -782,6 +799,30 @@ export const HMR_CLIENT_JS = `
|
|||||||
// Preserve a hydrated subtree only while its server hydration signature and
|
// Preserve a hydrated subtree only while its server hydration signature and
|
||||||
// behavior are unchanged. Component edits must replace and re-hydrate the
|
// behavior are unchanged. Component edits must replace and re-hydrate the
|
||||||
// old subtree or HMR will keep stale markup indefinitely.
|
// old subtree or HMR will keep stale markup indefinitely.
|
||||||
|
// HMR fetches fresh HTML whose inline scripts carry a NEW server nonce, but a
|
||||||
|
// document's CSP nonce is fixed at load and cannot be updated. Any node moved
|
||||||
|
// across therefore has to be re-stamped with the live document's nonce or the
|
||||||
|
// browser blocks it.
|
||||||
|
function adoptNonce(node) {
|
||||||
|
var nonce = currentDocumentNonce();
|
||||||
|
if (!nonce || !node || node.nodeType !== 1) return node;
|
||||||
|
var stamp = function (element) {
|
||||||
|
if (element.getAttribute("src")) return;
|
||||||
|
element.setAttribute("nonce", nonce);
|
||||||
|
try {
|
||||||
|
element.nonce = nonce;
|
||||||
|
} catch (error) {
|
||||||
|
// Read-only in some engines; the attribute above is what CSP checks.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (node.nodeName === "SCRIPT" || node.nodeName === "STYLE") stamp(node);
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
var nested = node.querySelectorAll("script,style");
|
||||||
|
for (var i = 0; i < nested.length; i++) stamp(nested[i]);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
function morph(from, to) {
|
function morph(from, to) {
|
||||||
if (from.__wrnexusHydrated) {
|
if (from.__wrnexusHydrated) {
|
||||||
var sameHydration =
|
var sameHydration =
|
||||||
@@ -790,16 +831,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
from.getAttribute("data-scope") === to.getAttribute("data-scope");
|
from.getAttribute("data-scope") === to.getAttribute("data-scope");
|
||||||
if (sameHydration) return;
|
if (sameHydration) return;
|
||||||
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
|
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
|
||||||
from.replaceWith(to.cloneNode(true));
|
from.replaceWith(adoptNonce(to.cloneNode(true)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncAttrs(from, to);
|
syncAttrs(from, to);
|
||||||
var fc = from.childNodes, tc = to.childNodes, i;
|
var fc = from.childNodes, tc = to.childNodes, i;
|
||||||
for (i = 0; i < tc.length; i++) {
|
for (i = 0; i < tc.length; i++) {
|
||||||
var t = tc[i], f = fc[i];
|
var t = tc[i], f = fc[i];
|
||||||
if (!f) { from.appendChild(t.cloneNode(true)); continue; }
|
if (!f) { from.appendChild(adoptNonce(t.cloneNode(true))); continue; }
|
||||||
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
|
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
|
||||||
from.replaceChild(t.cloneNode(true), f); continue;
|
from.replaceChild(adoptNonce(t.cloneNode(true)), f); continue;
|
||||||
}
|
}
|
||||||
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
|
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
|
||||||
if (f.nodeType === 1) morph(f, t);
|
if (f.nodeType === 1) morph(f, t);
|
||||||
@@ -808,8 +849,10 @@ export const HMR_CLIENT_JS = `
|
|||||||
}
|
}
|
||||||
function syncAttrs(from, to) {
|
function syncAttrs(from, to) {
|
||||||
var ta = to.attributes, fa = from.attributes, i;
|
var ta = to.attributes, fa = from.attributes, i;
|
||||||
for (i = 0; i < ta.length; i++) if (from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
|
// Never copy the incoming nonce: it belongs to the fetched document and
|
||||||
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
|
// would replace the live nonce this document's CSP actually allows.
|
||||||
|
for (i = 0; i < ta.length; i++) if (ta[i].name !== "nonce" && from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
|
||||||
|
for (i = fa.length - 1; i >= 0; i--) if (fa[i].name !== "nonce" && !to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed for tests; harmless (the client is injected only in dev).
|
// Exposed for tests; harmless (the client is injected only in dev).
|
||||||
@@ -829,8 +872,19 @@ function randomNonce(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
||||||
function hmrClientTag(nonce: string): string {
|
/** Path the dev asset server publishes the HMR client on. */
|
||||||
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
|
export const HMR_CLIENT_HREF = "/__wrnexus/hmr-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HMR client is served as an external module rather than inlined.
|
||||||
|
*
|
||||||
|
* A document's CSP nonce is fixed at load, so an inline script arriving from a
|
||||||
|
* later response — which is exactly what an HMR reload produces — can never
|
||||||
|
* carry a nonce this document accepts. An external file is covered by
|
||||||
|
* script-src 'self' and needs no nonce at all.
|
||||||
|
*/
|
||||||
|
function hmrClientTag(_nonce: string): string {
|
||||||
|
return `<script src="${HMR_CLIENT_HREF}"></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 403 for a rejected cross-site WebSocket handshake. */
|
/** 403 for a rejected cross-site WebSocket handshake. */
|
||||||
@@ -1449,7 +1503,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
|
|
||||||
async function handleApi(ctx: Context): Promise<Response> {
|
async function handleApi(ctx: Context): Promise<Response> {
|
||||||
const matched = router.matchApi(ctx.url.pathname);
|
const matched = router.matchApi(ctx.url.pathname);
|
||||||
if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
|
if (!matched) {
|
||||||
|
const fallback = router.matchApi("/api/404");
|
||||||
|
if (fallback && ctx.url.pathname !== "/api/404") {
|
||||||
|
const originalPath = ctx.url.pathname;
|
||||||
|
ctx.url.pathname = "/api/404";
|
||||||
|
try {
|
||||||
|
const response = await handleApi(ctx);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: 404,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
ctx.url.pathname = originalPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ error: "Not Found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Expose the canonical matched route to package dispatchers. A package may
|
// Expose the canonical matched route to package dispatchers. A package may
|
||||||
// contribute several URL paths from one module, and request URLs can be
|
// contribute several URL paths from one module, and request URLs can be
|
||||||
@@ -1606,7 +1676,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = renderNotFound();
|
let response: Response;
|
||||||
|
const fallback = router.matchPage("/404");
|
||||||
|
if (fallback && ctx.url.pathname !== "/404") {
|
||||||
|
const originalPath = ctx.url.pathname;
|
||||||
|
ctx.url.pathname = "/404";
|
||||||
|
try {
|
||||||
|
const rendered = await handlePage(ctx);
|
||||||
|
response = new Response(rendered.body, {
|
||||||
|
status: 404,
|
||||||
|
headers: rendered.headers,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
ctx.url.pathname = originalPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response = renderNotFound();
|
||||||
|
}
|
||||||
if (!isMobileRequest) return response;
|
if (!isMobileRequest) return response;
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
headers.set("x-wrnexus-original-status", "404");
|
headers.set("x-wrnexus-original-status", "404");
|
||||||
@@ -1886,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export function collectScripts(
|
|||||||
) {
|
) {
|
||||||
scripts.push("/__wrnexus/reactive.js");
|
scripts.push("/__wrnexus/reactive.js");
|
||||||
}
|
}
|
||||||
|
// Islands ship their own bootstrap, not the reactive runtime — a page whose
|
||||||
|
// only interactivity is an island must not pull in WRNexus's client runtime.
|
||||||
|
if (/\bdata-wrn-island=/.test(body)) scripts.push("/__wrnexus/islands.js");
|
||||||
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
|
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
|
||||||
if (
|
if (
|
||||||
/\bdata-wrn-theme-(toggle|set)\b/.test(body) ||
|
/\bdata-wrn-theme-(toggle|set)\b/.test(body) ||
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
name: "web",
|
||||||
|
origin: "http://127.0.0.1:3101",
|
||||||
|
domains: ["localhost", "web.localhost"],
|
||||||
|
publicOrigin: "http://localhost:3000",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function upgrade(origin: string, host: string): Request {
|
||||||
|
return new Request("http://" + host + "/__wrnexus/hmr", {
|
||||||
|
headers: { origin, host, upgrade: "websocket" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows an upgrade from the app's primary domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an upgrade from a secondary domain on a non-default port", () => {
|
||||||
|
// publicOrigin is built from domains[0], so a browser on web.localhost falls
|
||||||
|
// through to the domain list — where the origin host still carries :3000 and
|
||||||
|
// the configured domain does not. That mismatch denied every HMR socket on
|
||||||
|
// any domain but the first, leaving the client reconnecting forever.
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://web.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies an unrelated origin", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://evil.example:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies a lookalike suffix domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
defaultGatewayHostname,
|
defaultGatewayHostname,
|
||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
|
gatewayBrowserRpcHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
stripUntrustedInternalHeaders,
|
||||||
gatewayRestartDelay,
|
gatewayRestartDelay,
|
||||||
internalError,
|
internalError,
|
||||||
@@ -37,6 +39,35 @@ test("gateway disables compression for its internal proxy hop", () => {
|
|||||||
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gateway WebSocket bridge forwards validated application identity", () => {
|
||||||
|
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "session=abc",
|
||||||
|
connection: "Upgrade",
|
||||||
|
upgrade: "websocket",
|
||||||
|
"sec-websocket-key": "test-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayWebSocketBackendHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.origin).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.cookie).toBe("session=abc");
|
||||||
|
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
|
||||||
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
||||||
|
expect(headers.host).toBeUndefined();
|
||||||
|
expect(headers.connection).toBeUndefined();
|
||||||
|
expect(headers.upgrade).toBeUndefined();
|
||||||
|
expect(headers["sec-websocket-key"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
||||||
const request = new Request("http://localhost:3000/path", {
|
const request = new Request("http://localhost:3000/path", {
|
||||||
headers: { "x-wrnexus-internal": "1" },
|
headers: { "x-wrnexus-internal": "1" },
|
||||||
@@ -146,13 +177,40 @@ test("nested SSO proxy keeps the protected app's original request headers", () =
|
|||||||
expect(proxied.get("x-original-uri")).toBe("/settings");
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
|
test("the gateway proxies browser server functions but refuses private RPC routes", () => {
|
||||||
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
|
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false);
|
||||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("browser RPC proxy preserves CSRF credentials and trusts only the internal hop", () => {
|
||||||
|
const request = new Request(`http://web.localhost:3000${RPC_PATH_PREFIX}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "wrn-csrf=token",
|
||||||
|
"x-csrf-token": "token",
|
||||||
|
[RPC_INTERNAL_HEADER]: "forged",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayBrowserRpcHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.get("origin")).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.get("cookie")).toBe("wrn-csrf=token");
|
||||||
|
expect(headers.get("x-csrf-token")).toBe("token");
|
||||||
|
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
|
||||||
|
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
|
||||||
|
expect(headers.has("host")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
RPC_INTERNAL_HEADER,
|
RPC_INTERNAL_HEADER,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { HMR_CLIENT_JS } from "../src/runtime.ts";
|
||||||
|
|
||||||
|
test("the HMR client script is syntactically valid JavaScript", () => {
|
||||||
|
// HMR_CLIENT_JS is a TypeScript template literal, so an escape like \t or \n
|
||||||
|
// written unescaped is expanded by TypeScript into a real control character.
|
||||||
|
// Inside a regex literal that produces a raw newline, which is a syntax error
|
||||||
|
// that silently kills the whole HMR client in the browser.
|
||||||
|
expect(() => new Function(HMR_CLIENT_JS)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the HMR client contains no raw control characters inside regex literals", () => {
|
||||||
|
const regexLiterals = HMR_CLIENT_JS.match(/\/(?![/*])(?:\.|\[[^\]]*\]|[^/\n\r])+\//g) ?? [];
|
||||||
|
expect(regexLiterals.length).toBeGreaterThan(0);
|
||||||
|
for (const literal of regexLiterals) {
|
||||||
|
expect(literal).not.toMatch(/[\n\r\t]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,5 +14,6 @@ test("HMR replaces hydrated components when their server signature changes", ()
|
|||||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")');
|
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")');
|
||||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")');
|
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")');
|
||||||
expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)");
|
expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)");
|
||||||
expect(HMR_CLIENT_JS).toContain("from.replaceWith(to.cloneNode(true))");
|
// Cloned nodes are re-stamped with the live document nonce before insertion.
|
||||||
|
expect(HMR_CLIENT_JS).toContain("from.replaceWith(adoptNonce(to.cloneNode(true)))");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterAll, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { compile, generate, islandNamesFrom, resolveWrnImports } from "@wrnexus/compiler";
|
||||||
|
import { collectScripts } from "../src/script-selection.ts";
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
afterAll(() => {
|
||||||
|
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
const root = mkdtempSync(join(process.cwd(), ".island-e2e-"));
|
||||||
|
created.push(root);
|
||||||
|
mkdirSync(join(root, "app"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app", "Chart.tsx"),
|
||||||
|
`export default function Chart({ title }: { title: string }) { return <div>{title}</div>; }`,
|
||||||
|
);
|
||||||
|
writeFileSync(join(root, "app", "Card.wrn"), `component Card { view { <div>card</div> } }`);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a .wrn importing a .tsx emits an island marker and requests the bootstrap", () => {
|
||||||
|
const root = app();
|
||||||
|
const page = join(root, "app", "page.wrn");
|
||||||
|
const source = [
|
||||||
|
'import Chart from "./Chart"',
|
||||||
|
'import Card from "./Card"',
|
||||||
|
"page Home {",
|
||||||
|
" view {",
|
||||||
|
' <Chart title="Revenue" client:visible />',
|
||||||
|
" <Card />",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(page, source);
|
||||||
|
|
||||||
|
const ast = compile(source, page).ast;
|
||||||
|
const islands = islandNamesFrom(
|
||||||
|
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the .tsx import is an island; the .wrn component is not.
|
||||||
|
expect([...islands]).toEqual(["Chart"]);
|
||||||
|
|
||||||
|
const out = generate(ast, { islands });
|
||||||
|
expect(out).toContain('data-wrn-island="Chart"');
|
||||||
|
expect(out).toContain('data-wrn-island-strategy="visible"');
|
||||||
|
expect(out).toContain('data-component="Card"');
|
||||||
|
|
||||||
|
// Rendered island markup must pull in the island bootstrap.
|
||||||
|
expect(collectScripts('<div data-wrn-island="Chart"></div>')).toContain("/__wrnexus/islands.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a page with no .tsx imports emits no island markup and no island script", () => {
|
||||||
|
const root = app();
|
||||||
|
const page = join(root, "app", "plain.wrn");
|
||||||
|
const source = ['import Card from "./Card"', "page Plain {", " view { <Card /> }", "}"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
writeFileSync(page, source);
|
||||||
|
|
||||||
|
const ast = compile(source, page).ast;
|
||||||
|
const islands = islandNamesFrom(
|
||||||
|
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
|
||||||
|
);
|
||||||
|
expect(islands.size).toBe(0);
|
||||||
|
|
||||||
|
const out = generate(ast, { islands });
|
||||||
|
expect(out).not.toContain("data-wrn-island");
|
||||||
|
expect(collectScripts(out)).not.toContain("/__wrnexus/islands.js");
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { collectScripts } from "../src/script-selection.ts";
|
||||||
|
|
||||||
|
test("island markup pulls in the island bootstrap", () => {
|
||||||
|
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
|
||||||
|
expect(scripts).toContain("/__wrnexus/islands.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markup without islands ships no island bootstrap", () => {
|
||||||
|
expect(collectScripts(`<p>plain server html</p>`)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("islands alone do not pull in the reactive runtime", () => {
|
||||||
|
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
|
||||||
|
expect(scripts).not.toContain("/__wrnexus/reactive.js");
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { buildRouter } from "@wrnexus/router";
|
||||||
|
import { createHandlers, type RuntimeDeps } from "../src/runtime.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
|
||||||
|
|
||||||
|
function customNotFoundRuntime() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-not-found-"));
|
||||||
|
roots.push(root);
|
||||||
|
const app = join(root, "app");
|
||||||
|
mkdirSync(join(app, "pages"), { recursive: true });
|
||||||
|
mkdirSync(join(app, "api"), { recursive: true });
|
||||||
|
writeFileSync(join(app, "pages/404.ts"), "export default () => '';");
|
||||||
|
writeFileSync(join(app, "api/404.ts"), "export const GET = () => null;");
|
||||||
|
return createHandlers({
|
||||||
|
mode: "production",
|
||||||
|
hmr: false,
|
||||||
|
router: buildRouter(app),
|
||||||
|
loadModule: async (file) =>
|
||||||
|
file.includes(`${join("api", "404")}.ts`)
|
||||||
|
? {
|
||||||
|
GET: () =>
|
||||||
|
Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }),
|
||||||
|
}
|
||||||
|
: { default: () => "<main><h1>That page is gone</h1></main>" },
|
||||||
|
getMiddleware: async () => [],
|
||||||
|
assets: { serve: async () => null },
|
||||||
|
} satisfies RuntimeDeps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = { upgrade: () => false };
|
||||||
|
|
||||||
|
test("renders app/pages/404 with an HTTP 404 status", async () => {
|
||||||
|
const response = await customNotFoundRuntime().fetch(
|
||||||
|
new Request("https://example.test/missing"),
|
||||||
|
server,
|
||||||
|
);
|
||||||
|
expect(response?.status).toBe(404);
|
||||||
|
expect(await response?.text()).toContain("That page is gone");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses app/api/404 for unmatched API routes and preserves headers", async () => {
|
||||||
|
const response = await customNotFoundRuntime().fetch(
|
||||||
|
new Request("https://example.test/api/missing"),
|
||||||
|
server,
|
||||||
|
);
|
||||||
|
expect(response?.status).toBe(404);
|
||||||
|
expect(response?.headers.get("x-custom")).toBe("yes");
|
||||||
|
expect(await response?.json()).toEqual({ code: "CUSTOM_NOT_FOUND" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRecycleMonitor } from "../src/recycle.ts";
|
||||||
|
|
||||||
|
/** Fresh monitor with a small threshold so tests stay readable. */
|
||||||
|
function monitor(overrides: Partial<Parameters<typeof createRecycleMonitor>[0]> = {}) {
|
||||||
|
const recycled: string[] = [];
|
||||||
|
const control = createRecycleMonitor({
|
||||||
|
threshold: 3,
|
||||||
|
idleMs: 1000,
|
||||||
|
onRecycle: (reason) => recycled.push(reason),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { control, recycled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stays quiet below the rebuild threshold", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
control.tick(10_000);
|
||||||
|
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles once rebuilds pass the threshold and the server goes idle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(1_500);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for the idle gap rather than cutting off active work", () => {
|
||||||
|
// Recycling mid-request would drop it. The gap is the whole point.
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(500);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.recordRequest(900);
|
||||||
|
control.tick(1_400);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.tick(2_000);
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles only once even if it keeps being ticked", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(5_000);
|
||||||
|
control.tick(6_000);
|
||||||
|
control.tick(7_000);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server that never served a request can still recycle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.tick(9_999);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports how many rebuilds are being retained", () => {
|
||||||
|
const { control } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
|
||||||
|
expect(control.retained()).toBe(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.9",
|
"version": "0.8.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user