@ape-egg/vibe 2.1.22 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.0] - 2026-07-02
4
+
5
+ ### Changed
6
+
7
+ - **SPA shell ships without `vibe-fouc`** (compiler `spa.rs`) — `vibe-fouc` absence is the compiled-mode marker (the runtime gates hyperspeed manifest loading on it), so the shell carrying it silently ran in runtime mode and never fetched its own manifest — and broke consumers keying compiled-detection off the marker (Battle Brawlers' COMPILED badge). The shell now strips the attribute from the body-attr union instead of adding it: nothing flashes — the outlet is empty and fragments hydrate off-DOM before insertion. Test: `tests/compiler/spa/` (body-attr union asserts the absence); the compiled SPA e2e now exercises the shell's manifest-driven boot.
8
+ - **SPA shell vibe-import fallback is browser-resolvable** (compiler `spa.rs`) — when no page imports vibe directly (the import lives in a bundled app-boot module, the game-stack shape), the shell previously emitted the bare `@ape-egg/vibe` specifier, which no browser loads without an import map. The fallback is now the conventional `/node_modules/@ape-egg/vibe/index.js` (spa.js derived from it); harvested page import styles still win when present. Test: `tests/compiler/spa-bundled-vibe/`.
9
+ - **Defaults semantics folded into `vibe()` itself; `@ape-egg/vibe/defaults` removed** (`index.js`, `defaults.js` (deleted), compiler `spa.rs`) — `vibe(state)` called once booted now seeds missing keys only (`applyDefaults`, exported from the main entry) instead of `Object.assign`-clobbering live state. "Initial state, declared again" finally means the same thing in both lifetimes, so the separate defaults entry and the compiler SPA mode's fragment import rewrite are gone — fragment scripts are carried byte-identical with the page's own import. 2.2.0 was never published (npm is at 2.1.22), so nothing external loses the entry. Pre-boot behavior is untouched: on a fresh document load MPA behavior is byte-identical. Tests: `tests/unit/defaults.test.js` retargeted to the main entry; `tests/compiler/spa/` + `tests/compiler/spa-inline-children/` now assert imports pass through untouched; the compiled-SPA state-survival e2e proves the folded semantics end-to-end through real fragments.
10
+
11
+ ### Added
12
+
13
+ - **Watch mode hot-reloads its config** (compiler `watcher.rs`, `config.rs`) — a running `--watch` was a spawn-time config snapshot: flipping `"spa"` (or any vibe-compiler option) in package.json did nothing until you found which process owned the output lock and restarted it by hand. The watcher now watches package.json explicitly (outside the source tree and past `skip_files`, which routinely lists it for copying) and, when the loaded config actually differs from the spawn baseline (formatting-only writes are ignored via `Config: PartialEq`), restarts itself in place: lock released, then `exec` of the same binary with the same argv — CLI flag overrides re-apply with perfect parity, and the pid is preserved so a parent process supervising the child (a dev server watching for compiler exit) never notices. Full recompile under the new config follows naturally, and the standard output-clean removes the other mode's artifacts. Test: `tests/compiler/watch-config-reload/` (spawn `--watch` under `spa: true`, flip to `false`, assert the output switches shape live).
14
+ - **Fetched-component mounts are fouc-covered until settled** (`runtime/component.js`) — finalize inserts a fetched component's content and the observer hydrates it across LATER mutation batches, with paints in between: until hydration lands, selectors keyed on hydrated attributes (a name-bound `<page @[page.name]>` driving `page[pvp]`-scoped rules) don't match and the content flashes unstyled — ~300ms of raw skeleton on every Battle Brawlers SPA navigation. The replacement wrapper now carries `vibe-fouc` from insertion until its subtree settles (`shouldCleanup(wrapper)` — the page-ready predicate — checked per afterDomMutation; a wrapper unmounted mid-hydration releases the hook). Applies to every runtime-fetched component, not just SPA outlets; nested fetched children compose (the parent stays covered until they settle too). Test: `tests/e2e/spa-router.spec.js` ("stays fouc-covered until its subtree settles" — deterministic via a held child fetch, both modes).
15
+ - **Late `$.on('ready')` fires immediately** (`runtime/index.js`) — the ready phase happens once; a listener registered after it now runs on the spot (same try/catch as phase-time listeners) instead of silently never firing, with parity to the already-late-safe `$.ready` promise. Late registration is the SPA norm: fragment scripts execute on mount, long after the shell booted — an app-boot module imported by a fragment registers its ready work exactly there. Test: `e2e-runtime/late-ready.html` + `tests/e2e/late-ready.spec.js` (both modes: pre-boot control listener fires at the phase, post-ready listener fires immediately).
16
+ - **Named routes — `$.page.name`** (`spa.js`) — `resolve()` and the default `onNavigate` now carry `name`, the route slug: segments joined with dashes, params flattened to their bare name (`'/' → 'home'`, `'/pve/:id' → 'pve-id'`, `'/docs/:rest*' → 'docs-rest'`, the `'*'` fallback keeps its literal `'*'`). Stable across param values, so markup hangs page-scoped attributes and active checks on it — `<page @[page.name]>`, `page.name.startsWith('pve')` — the shape MPA route parsers already derive, so page code ports to SPA untouched. Contract is now `$.page = { path, route, params, src, name }`. Tests: `tests/unit/spa.test.js` ("route names" — root, static, multi-segment, param flattening + stability, catch-all, `'*'`), name assertions in both SPA e2e suites.
17
+ - **Keyed outlet — `key` on fetched components** (`runtime/parse.js`, `runtime/hydrate.js`, `runtime/component.js`; compiler `spa.rs`, 2.1.1 → 2.2.0) — `key="@[expr]"` on a `<component src>` declares the mount's identity: when the resolved key changes, the component remounts even though the src is unchanged. The compiler's SPA shell now emits `<component src="@[page.src]" key="@[page.path]"></component>`, so param→param navigation on the same route (`/brawlers/0 → /brawlers/1` — same fragment src) mounts fresh, exactly like the MPA reload it replaces. Mechanics: fetched components capture the bound key alongside src (every other attribute stays a raw prop); a key change force-remounts the current src through `forceRemount` — a pending fetch is already a fresh mount (a src change in the same flush deduped for free) and an unmounted wrapper's first mount stays owned by the normal pass; finalize rides `data-vibe-key` + the last resolved key across wrapper replacements the same way `data-vibe-src` travels, so the knowledge survives arbitrarily many navigations. `src` and `key` are the wrapper's own contract and are never passed to the component as props (`data-vibe-*` transport attributes likewise excluded). Tests: `tests/e2e/spa-router.spec.js` + `tests/e2e/spa-compiled.spec.js` (param→param remount runs the fragment script again, both modes + through the real generated shell), `tests/compiler/spa/` + `tests/compiler/spa-inline-children/` (shell emits the keyed outlet).
18
+
19
+ ## [2.2.1] - 2026-07-02
20
+
21
+ ### Fixed
22
+
23
+ - **`nodeModulesAsIs` copied gigabytes of build artifacts through a symlinked local package** (`compiler/src/compiler/compile.rs`, compiler 2.1.0 → 2.1.1) — the as-is copy walks `node_modules` blindly, and a dev-workspace symlink (`node_modules/@ape-egg/vibe → <local vibe repo>`) dragged the package's cargo `target/` along: a 15MB copy became **4.2GB and ~4.3s per clean compile**. `copy_dir_recursive` now honors the Cache Directory Tagging spec — any directory carrying a signed `CACHEDIR.TAG` (cargo stamps `target/` with exactly this so copy/backup tools can skip it) is skipped, with the signature header verified so the check can't false-positive on an ordinary file name. Same fixture: 16MB in 9ms. Nested `node_modules` inside packages, dotdirs like `.bin`, and everything else copy as before — the skip is opt-in by the directory's own declaration, not name-based guessing. Test: `compile.rs` (`as_is_copy_skips_cachedir_tagged_directories`). Affects every project that symlinks the local vibe package with `nodeModulesAsIs: true` (Battle Brawlers' compiled builds included).
24
+
25
+ ## [2.2.0] - 2026-07-02
26
+
27
+ ### Added
28
+
29
+ - **`@ape-egg/vibe/spa` — standalone SPA router** (`spa.js` (new), exported from `package.json`) — the tier between a handrolled router and compiler SPA mode, shaped exactly like `hot-module-refresh.js`: a flat module importing nothing from the runtime, depending only on the public `window.$` surface, usable without Vibe entirely via one seam. One contract: `$.page = { path, route, params, src }`; a reactive `<component src="@[page.src]">` turns that into the route outlet. `resolve(location, routes)` is the pure core (accepts anything with a `.pathname` or a bare string; `:param` captures one segment, trailing `:name*` zero-or-more, `'*'` is a position-independent declared fallback applied only after every real route missed; tables are pre-sorted most-specific-first, first match wins; trailing slashes resolve). `setupSpa({ routes, onNavigate? })` wires one document-level click listener (claims same-origin, unmodified, untargeted clicks whose pathname matches a REAL route — `'*'` never claims, same-page hash anchors keep the native jump, everything else navigates natively, which is what makes mixed MPA/SPA output work), pushState + one fresh-object `$.page` assignment + `document.title` swap when the route carries one + scroll-to-top; `popstate` re-resolves WITH the `'*'` fallback (symmetric with deep-link entry) and doesn't scroll — the browser restores. Returns `{ navigate, dispose }`: `navigate(path)` claims like a click and falls back to a native load for unrouted paths; `dispose()` releases the listeners. The default `onNavigate` reads `window.$` at call time; passing a custom one makes the module a pure router. Tests: `tests/unit/spa.test.js` (resolve: params, catch-alls at every depth, specificity, `'*'`, no-match, location-likes), `e2e-runtime/spa-router.html` + `tests/e2e/spa-router.spec.js` (both modes: claiming without document loads, params → bindings, title swaps, global-state survival vs fresh component mounts, unmount teardown, popstate both directions, native fallthrough, hash anchors, programmatic navigate, dispose).
30
+ - **`@ape-egg/vibe/defaults` — the root entry with defaults semantics for booted state** (`defaults.js` (new), exported from `package.json`) — `vibe()` state is app-lifetime under SPA: already booted, only keys that do not yet exist on `$` are set (`applyDefaults`, shallow and key-level — exported pure for reuse), so a re-mounted page fragment's `vibe({ ...globalState, notifications: [] })` seeds on first mount and never clobbers live state after; pre-boot it delegates to `vibe()` unchanged, so on a fresh document load defaults ≡ assign and MPA behavior is byte-identical. The documented rule this encodes: `vibe()` state = app-lifetime, `component()` state = mount-lifetime — per-visit-reset state belongs in a component (the Timer pattern). Tests: `tests/unit/defaults.test.js` (missing keys set; existing — including falsy and `undefined`-valued — never overwritten; shallow; returns target), exercised end-to-end by the compiler SPA e2e's state-survival spec.
31
+ - **Compiler SPA mode — `"spa": true` / `--spa`** (`compiler/src/compiler/spa.rs` (new), `compile.rs`, `watcher.rs`, `config.rs`, `main.rs`, compiler 2.0.3 → 2.1.0) — compiles the SAME MPA `pages/` tree into: **page fragments** at `output/components/vibe-spa/<pages-relative-path>` (body content, head `<style>` tags prepended, page `<script type="module">` carried with ONE rewrite — the vibe import specifier redirected to the defaults entry; body module scripts get the same redirect; `componentsAsIs` composes: `true` keeps child `<component src>` tags runtime-fetched and deduped through the component cache, `false` — the default — inlines children INTO each fragment so every route is one self-contained fetch, with each inlined child carried in the compiled-document form: `ComponentTagger` runs per fragment, so wrappers get deterministic `data-vibe-component-id`s, `this.` bindings and `$.this.` handlers are rewritten to those ids, and child scripts stay `type="vibe-module"`); a **generated route table** (`$param` → `:param`, terminal `$$name` → `:name*`, terminal `index` serves its directory; titles harvested from each page's `<title>`; ordered non-catch-alls-deepest-first-statics-before-params, then catch-alls — the exact-beats-param and everything-beats-catch-all rules a full table needs on top of scanRoutes' deepest-first); and a **composed shell** at `output/index.html` — plain tier-2 runtime-Vibe code (deduped head union where `<meta>` keeps only the set common to every page — page-specific meta dropped with a verbose note — links/scripts stack first-seen; the `/` route's title; body attribute union + `vibe-fouc`, divergences noted in verbose; a generated boot script reusing the pages' own vibe import style; the `<component src="@[page.src]"></component>` outlet). SPA output has **no** `pages/` directory, **no** per-page manifests, and **no** per-page stamping — fragments are runtime-parsed on mount (per-route lazy loading falls out of the bound src for free). The shell still gets a manifest but is deliberately **unstamped**: it is served at every route path while manifests resolve by URL, and stamping would have stripped the raw outlet binding — deep links would have hydrated a dead outlet (stamp-skip is keyed in `generate_file_manifest`). The fragments mirror prunes orphans (deleted pages leave no stale fragment), `components/vibe-spa` in source is reserved (hard error), a source-root `index.html` being shadowed by the shell warns, and an unmount-hygiene warning names any page script that starts side effects (`setInterval`/`setTimeout`/`addEventListener`/`new WebSocket`) without referencing `$.on('unmount'`. Watch mode: any pages-tree change (edit/add/delete, or a component edit whose dependents are pages) re-runs the whole SPA pass — fragments, shell, route table, orphan prune, shell manifest — in ~10ms, with component-cache invalidation shared with the per-page path. Config parsing tolerates the future per-page object form (`"spa": {...}` reads as enabled instead of silently resetting the whole section). Deferred by design until the compiled branch-script double-run is fixed: the inline fragment mode (`componentsAsIs: false` semantics — fragments inlined into the shell behind route conditionals). Tests: `spa.rs` + `config.rs` + `compile.rs` Rust units (route grammar, ordering, import rewrite forms, hygiene, tolerant config, watch-cycle sync incl. orphan prune), `tests/compiler/spa/` (14 bun tests over a mini pages tree: shell composition, head dedup + meta drop, body-attr union, fragment content and rewrites, no pages dir / no page manifests, shell manifest carries the binding, warnings), `tests/e2e-spa-app/` + `tests/e2e/spa-compiled.spec.js` (a real compiled app driven in the browser: root entry, no-match entry leaves the outlet empty with no garbage fetch, claimed navigation, **defaults-merge state survival across navigation**, unmount teardown, params, popstate, scoped catch-all claiming, native fallthrough to a plain MPA document in the same output, shared child components).
32
+
33
+ - **Fetched mounts execute inlined `vibe-module` scripts** (`runtime/component.js`) — a freshly fetched component's subtree is the fourth delivery mode after boot, conditional branches, and iteration rows: `processSingle`'s finalize now runs `executeCompiledComponentScriptsIn` on the mounted wrapper, so build-inlined children inside a fetched SPA fragment register their `component({...})` state under their build-tagged ids and the rewritten `_cN` bindings hydrate. A no-op everywhere else — runtime component sources and the compiler's raw components mirror never contain `vibe-module` scripts. Locked in by `tests/compiler/spa-inline-children/` (fragment carries tagged wrapper + vibe-module script + `_cN`-rewritten bindings/handlers, props and slot stamped, no child fetch) and verified in-browser: child state is live and reactive inside a fetched fragment, resets per visit (mount-lifetime), zero child network fetches.
34
+
35
+ ### Fixed
36
+
37
+ - **An unresolved reactive component src fetched a garbage URL instead of mounting nothing** (`runtime/hydrate.js`, `runtime/component.js`) — with `<component src="@[page.src]">` and `page.src` unset (a no-match deep link: the SPA contract says the outlet stays empty, no built-in 404), hydration stringified the binding to `"undefined"` and the component pass happily fetched it — and where hydration left the raw attribute, the initial component scan fetched the literal `@[page.src]` as a URL. Root fix at both layers: hydration now resolves nullish binding values to empty and, when nothing resolves, moves the authored binding onto `data-vibe-src` — the established transport `parse.js` already reads — and drops the fetchable `src`, so the component scan and the fouc/cleanup gate both treat the outlet as settled rather than pending; `remountComponent`'s pre-fetch early-return (which trusts a pending boot pass to pick the value up) now applies only when the element actually had a `src` attribute for that pass to see — a declaration-form wrapper was invisible to it, and the observer doesn't watch attributes, so hydration owns its first fetch too. Locked in by the compiled SPA e2e: entry at an unrouted URL mounts nothing and the request log contains no `undefined`/`@[` fetches; late resolution (navigating from a no-match entry to a real route) mounts normally.
38
+ - **A relative `--cwd` let watch mode write INTO the source tree** (`compiler/src/config.rs`, compiler 2.1.0) — every derived path (source, output, pages) joins from `working_dir`, and filesystem-watch events always carry absolute paths, so a relative `--cwd` made `strip_prefix` fail throughout the watcher; in the incremental manifest pass the un-stripped absolute path then escaped `output_dir.join(...)` entirely (an absolute join replaces the base) and the pass **stamped a source page in place and wrote a manifest beside it**. `Config::load` now canonicalizes `working_dir` up front — one root fix that makes every downstream base absolute. (The repo's own `bun dev` always passed an absolute `$OLDPWD`, which is why this never bit before.)
39
+
3
40
  ## [2.1.22] - 2026-07-02
4
41
 
5
42
  ### Added
package/README.md CHANGED
@@ -247,6 +247,59 @@ $.on('afterDomMutation', () => {}); // after every MutationObserver batch
247
247
 
248
248
  `$.ready` is also exposed as a Promise (`await $.ready`), useful for code that captured `window.$` before boot.
249
249
 
250
+ ```javascript
251
+ $.on('unmount', () => {}); // scope-resolved teardown: component scripts → that component
252
+ // unmounts; page level → pagehide
253
+ ```
254
+
255
+ ### SPA Router (`@ape-egg/vibe/spa`)
256
+
257
+ A standalone client-side router built on one contract: `$.page = { path, route, params, src, name }`. Point a reactive component src at it and the outlet is your route view:
258
+
259
+ ```html
260
+ <script type="module">
261
+ import vibe from '@ape-egg/vibe';
262
+ import { setupSpa, resolve } from '@ape-egg/vibe/spa';
263
+
264
+ const routes = [
265
+ { route: '/brawlers/:index', src: '/components/brawler.html', title: 'Brawler' },
266
+ { route: '/docs/:rest*', src: '/components/docs.html' },
267
+ { route: '/', src: '/components/home.html', title: 'Home' },
268
+ { route: '*', src: '/components/lost.html' },
269
+ ];
270
+
271
+ window.$ = vibe({ page: resolve(location, routes) ?? {} });
272
+ setupSpa({ routes });
273
+ </script>
274
+
275
+ <component src="@[page.src]" key="@[page.path]"></component>
276
+ ```
277
+
278
+ **Route grammar** (shared with the compiler's route table): literal segments, `:param` captures one segment, a trailing `:name*` captures zero or more (`/docs` matches with `rest: ''`), and `'*'` is the declared no-match fallback. Tables are pre-sorted most-specific-first; `resolve` returns the first match. `resolve(location, routes)` is pure — it accepts anything with a `.pathname` (or a bare path string) and returns `{ path, route, params, src, name, title? }` or `null`.
279
+
280
+ **Route names**: `name` is the route slug — segments joined with dashes, params flattened to their bare name: `'/' → 'home'`, `'/pve/:id' → 'pve-id'`, `'/docs/:rest*' → 'docs-rest'`, the `'*'` fallback keeps its literal `'*'`. Stable across param values, so markup hangs page-scoped attributes and active checks on it: `<page @[page.name]>`, `page.name.startsWith('pve')`.
281
+
282
+ **Keyed outlet**: `key` on a fetched `<component>` declares its identity — when the resolved key changes, the component remounts even if `src` is unchanged. With `key="@[page.path]"`, param→param navigation on the same route (`/brawlers/0 → /brawlers/1` — same fragment src) mounts fresh, exactly like an MPA reload on the new URL. `src` and `key` are the wrapper's own contract and are never passed to the component as props.
283
+
284
+ **Link claiming**: one document-level click listener claims same-origin, unmodified, untargeted clicks whose pathname matches a **real** route — pushState + a fresh `$.page` assignment + `document.title` swap when the route carries one, scroll to top. Everything else navigates natively: other origins, modified clicks, `target`/`download` links, same-page hash anchors, and unrouted paths — which is what makes mixed MPA/SPA output work. `'*'` never claims a click; it only resolves deep-link entries and popstate. `popstate` re-resolves (including `'*'`) without scrolling — the browser restores position.
285
+
286
+ **No match, no `'*'`**: `$.page.src` stays unset and the outlet mounts nothing. There is no built-in 404.
287
+
288
+ `setupSpa({ routes, onNavigate? })` returns `{ navigate, dispose }`. `navigate(path)` claims like a link click (unrouted paths get a native load); `dispose()` removes the listeners. Pass a custom `onNavigate(resolved)` and the module is a pure router — parse/claim/history only, no Vibe in sight.
289
+
290
+ ### App-Lifetime State Defaults (built into `vibe()`)
291
+
292
+ `vibe()` called once the app is booted applies **defaults semantics**: only keys that do not yet exist on `$` are set.
293
+
294
+ ```javascript
295
+ import vibe from '@ape-egg/vibe';
296
+ vibe({ ...globalState, notifications: [] }); // first mount seeds, re-mounts never clobber
297
+ ```
298
+
299
+ Pre-boot, state accumulates and boot is queued as always — on a fresh document load nothing changes, MPA behavior is byte-identical. Once booted, "initial state, declared again" seeds missing keys only: under SPA a re-mounted page fragment's `vibe({...})` call re-runs on every visit, and live state (notifications, session, timers) is never reset to initial values. No separate entry, no compiler rewrite — the same import does the right thing in both lifetimes. The pure `applyDefaults(target, state)` is exported for reuse.
300
+
301
+ **The rule**: `vibe()` state is app-lifetime; `component()` state is mount-lifetime (resets per visit). Per-page-reset state belongs in a component — that's the Timer pattern.
302
+
250
303
  ### Subtree Reconciliation (advanced)
251
304
 
252
305
  `$.reconcile(el, html)` and `$.renderComponent(rawHtml, props, slot, opts)` are public-but-advanced APIs used by the vite plugin's HMR path. Their shape may evolve; treat them as plumbing rather than application code for now.
@@ -342,6 +395,7 @@ bunx vibe compile --node-modules-as-is # Copy node_modules as-is
342
395
  bunx vibe compile --components-as-is # Skip component inlining
343
396
  bunx vibe compile --runtime-as-is # Skip manifest generation
344
397
  bunx vibe compile --iterations-as-is # Skip iteration optimization
398
+ bunx vibe compile --spa # Compile the pages tree to SPA output
345
399
  ```
346
400
 
347
401
  Or via npm scripts:
@@ -377,7 +431,8 @@ Add to your `package.json`:
377
431
  "nodeModulesAsIs": false,
378
432
  "componentsAsIs": false,
379
433
  "runtimeAsIs": false,
380
- "iterationsAsIs": false
434
+ "iterationsAsIs": false,
435
+ "spa": false
381
436
  }
382
437
  }
383
438
  ```
@@ -389,6 +444,7 @@ Add to your `package.json`:
389
444
  - `componentsAsIs: false` — Inline components (default) or keep separate for runtime
390
445
  - `iterationsAsIs: false` — Optimize iterations (default) or use runtime rendering
391
446
  - `runtimeAsIs: false` — Generate manifest (default) or skip for runtime-only
447
+ - `spa: false` — Compile the pages tree to SPA output: fragments + route table + shell (see SPA Mode)
392
448
 
393
449
  **Defaults** (when no config):
394
450
 
@@ -445,6 +501,47 @@ Features:
445
501
  - **Debounced** — 300ms debounce prevents excessive compilation during rapid changes
446
502
  - **Delta output** — First compile shows full output, subsequent compiles show only changes
447
503
 
504
+ ### SPA Mode
505
+
506
+ `"spa": true` (or `--spa`) compiles the same MPA `pages/` tree into a single-page app — authors change **nothing** about how pages are written:
507
+
508
+ ```json
509
+ { "vibe-compiler": { "spa": true } }
510
+ ```
511
+
512
+ The pages tree becomes three things:
513
+
514
+ 1. **Page fragments** at `/components/vibe-spa/<pages-relative-path>` — each page's body content, with its head `<style>` tags prepended and its `<script type="module">` carried along byte-identical. No rewrites: `vibe()` itself applies defaults semantics once booted (see App-Lifetime State Defaults above), so page state gets app-lifetime behavior with the page's own import.
515
+ 2. **A generated route table** — `pages/brawlers/$index.html` → `{ route: '/brawlers/:index', src: '/components/vibe-spa/brawlers/$index.html', title: <harvested from the page's <title>> }`. `$param` → `:param`, terminal `$$name` → `:name*`, terminal `index` serves its directory path. Sorted most-specific-first.
516
+ 3. **A composed shell** at the output root `/index.html` — plain runtime-Vibe code: the deduped union of every page's head resources (`<meta>` keeps only the set common to all pages; page-specific meta is dropped with a verbose note), the `/` route's title, the union of page body attributes minus `vibe-fouc`, one generated boot script (`resolve` seeds `$.page`, `setupSpa` wires navigation — imports reuse the pages' own import style), and the keyed route outlet: `<component src="@[page.src]" key="@[page.path]"></component>` (a path change remounts the fragment even when the route — and so the src — is unchanged: param→param navigation mounts fresh, like the MPA reload it replaces).
517
+
518
+ **What SPA mode skips**: per-page stamped HTML, per-page manifests, and the `pages/` output directory. Fragments are runtime-parsed on mount (per-route lazy loading falls out of the reactive src for free); the shell itself gets a manifest but is deliberately left unstamped — it is served at every route path, so its first paint is location-dependent by nature. The shell ships **without** `vibe-fouc`: its absence is the compiled-mode marker (it gates hyperspeed manifest loading), and there is nothing to flash — the outlet is empty and fragments hydrate off-DOM before insertion.
519
+
520
+ **Deployment**: one rewrite — every route serves `/index.html`; `/components/**`, `/vibe-hyperspeed/**`, and assets serve as files. The vercel.json shape:
521
+
522
+ ```json
523
+ {
524
+ "rewrites": [
525
+ { "source": "/((?!components/|vibe-hyperspeed/|nodemodules/|.*\\..*).*)", "destination": "/index.html" }
526
+ ]
527
+ }
528
+ ```
529
+
530
+ Dev-server equivalents: any "history API fallback" option (`http-server` can't rewrite; `vite preview`, `serve -s`, and Caddy `try_files` all can).
531
+
532
+ **Watch mode**: `--spa --watch` re-runs the SPA pass on any pages-tree change — edited pages re-transform, added/removed pages resync the route table and prune orphan fragments, title/head edits recompose the shell, and the shell's manifest refreshes.
533
+
534
+ **Current scope**: `spa: true` converts the entire pages tree (per-page selection is planned as a config-object form — parsing already tolerates it). Fragments ship as separate fetched files either way; `componentsAsIs` decides their children: `false` (the default) inlines child components into each fragment, making every route one self-contained fetch, while `true` keeps children as runtime `<component src>` fetches deduped across routes by the component cache. Compiling every fragment into the shell itself behind route conditionals (single document, zero per-route fetches) is specified but lands only once compiled branch-scripts stop double-running. Pages that wrap themselves in a Layout component remount it per navigation — exactly like an MPA reload; persistent chrome is out of scope for now. `components/vibe-spa/` in your source is reserved.
535
+
536
+ ### MPA → SPA Compliance Contract
537
+
538
+ SPA mode assumes; this contract defines. Pages that follow it compile to MPA today and SPA tomorrow with no edits:
539
+
540
+ 1. **Side effects register teardown.** Anything a page script starts — `setInterval`, `addEventListener`, sockets — must be released in `$.on('unmount', …)`. Under MPA that callback fires on `pagehide`; under SPA it fires when the page fragment unmounts on navigation. The compiler emits a warning for page scripts that start side effects and never reference `$.on('unmount'`.
541
+ 2. **`vibe()` state is app-lifetime; `component()` state is mount-lifetime.** Under SPA, a page's `vibe({...})` seeds missing keys only (defaults semantics) — it never resets live state. State that must reset on every visit belongs in a `component()`.
542
+ 3. **Head resources are shell-safe.** Stylesheets and scripts linked from a page's `<head>` end up in the shared shell head (deduped union) — they must be safe to load once for the whole app. Page-specific styling goes in `<style>` tags (which travel with the fragment) or components, not head links. Page-specific `<meta>` is dropped.
543
+ 4. **No full-document assumptions.** Pages don't rely on `window.onload`-era patterns or being the entire document — a page's body becomes a fragment inside a live shell. Use absolute URLs for assets and component srcs (the fragment is served from a different path than the page was authored at).
544
+
448
545
  ### Component System
449
546
 
450
547
  Components are automatically inlined during compilation with full support for props and slots:
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "2.0.3"
1602
+ version = "2.2.0"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "2.0.3"
3
+ version = "2.2.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]