@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Vibe
2
2
 
3
- **Version 2.0.2 (Beta)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 3.0.0** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
4
+
5
+ ## Security model & CSP
6
+
7
+ Vibe escapes by default: `@[expr]` renders as text, never markup. The one deliberate exception is `@[$.unsafe(trustedHtml)]`, which sets `innerHTML` and is for trusted input only.
8
+
9
+ What you must know before adopting: **Vibe's engine evaluates binding expressions with `new Function` and uses inline `on*` attributes as its event model.** A site running Vibe therefore needs a Content-Security-Policy that allows `'unsafe-eval'` and inline event handlers — i.e. it cannot deploy a strict CSP. Vibe itself is not an XSS vector, but strict CSP is a browser-level safety net against *other* injection bugs anywhere on a page, and Vibe requires that net loosened.
10
+
11
+ If you ship to an environment that mandates strict CSP (banks, healthcare, government, security-reviewed enterprise), Vibe is currently the wrong tool. For everything else — personal sites, games, dashboards, internal tools, most product work — this is the same posture as running Vue's in-browser template compiler or Alpine.js, and it is a documented trade-off, not an accident.
4
12
 
5
13
  No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
6
14
 
@@ -212,7 +220,7 @@ Multi-segment paths (`@[this.user.profile.name]`), conditionals (`<!-- if this.e
212
220
  How it works:
213
221
 
214
222
  1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper, registers state at `$[id]`, and tags the wrapper with `data-vibe-component-id`
215
- 2. Internally it calls `ensureBoot()` (from `@ape-egg/vibe/boot`), which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once, even if multiple `<component>` blocks call `component()`
223
+ 2. Internally it triggers the boot pipeline, which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once, even if multiple `<component>` blocks call `component()`
216
224
  3. From there, `@[this.X]`, `onclick="this.fn()"`, and `<!-- if this.X -->` work as documented
217
225
 
218
226
  Multiple drop-in blocks on the same page each get their own state bucket. They can read each other's state via global `$['_c0'].count` if they need to coordinate, but in most drop-in cases they're independent.
@@ -247,6 +255,59 @@ $.on('afterDomMutation', () => {}); // after every MutationObserver batch
247
255
 
248
256
  `$.ready` is also exposed as a Promise (`await $.ready`), useful for code that captured `window.$` before boot.
249
257
 
258
+ ```javascript
259
+ $.on('unmount', () => {}); // scope-resolved teardown: component scripts → that component
260
+ // unmounts; page level → pagehide
261
+ ```
262
+
263
+ ### SPA Router (`@ape-egg/vibe/spa`)
264
+
265
+ 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:
266
+
267
+ ```html
268
+ <script type="module">
269
+ import vibe from '@ape-egg/vibe';
270
+ import { setupSpa, resolve } from '@ape-egg/vibe/spa';
271
+
272
+ const routes = [
273
+ { route: '/brawlers/:index', src: '/components/brawler.html', title: 'Brawler' },
274
+ { route: '/docs/:rest*', src: '/components/docs.html' },
275
+ { route: '/', src: '/components/home.html', title: 'Home' },
276
+ { route: '*', src: '/components/lost.html' },
277
+ ];
278
+
279
+ window.$ = vibe({ page: resolve(location, routes) ?? {} });
280
+ setupSpa({ routes });
281
+ </script>
282
+
283
+ <component src="@[page.src]" key="@[page.path]"></component>
284
+ ```
285
+
286
+ **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`.
287
+
288
+ **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')`.
289
+
290
+ **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.
291
+
292
+ **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.
293
+
294
+ **No match, no `'*'`**: `$.page.src` stays unset and the outlet mounts nothing. There is no built-in 404.
295
+
296
+ `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.
297
+
298
+ ### App-Lifetime State Defaults (built into `vibe()`)
299
+
300
+ `vibe()` called once the app is booted applies **defaults semantics**: only keys that do not yet exist on `$` are set.
301
+
302
+ ```javascript
303
+ import vibe from '@ape-egg/vibe';
304
+ vibe({ ...globalState, notifications: [] }); // first mount seeds, re-mounts never clobber
305
+ ```
306
+
307
+ 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.
308
+
309
+ **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.
310
+
250
311
  ### Subtree Reconciliation (advanced)
251
312
 
252
313
  `$.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.
@@ -297,9 +358,11 @@ No need for immutable update patterns or spread operators. Just mutate and Vibe
297
358
  1. **Proxy-based state** — `window.$` intercepts property changes
298
359
  2. **Deep reactivity** — Nested mutations trigger updates automatically (`$.obj.nested.prop = x`)
299
360
  3. **DOM parsing** — Finds all `@[...]` bindings on load
300
- 4. **Surgical updates** — Only affected elements re-render
361
+ 4. **Auto-tracked subscriptions** — Every binding, conditional, and iteration records which state keys its expression read during evaluation; a write dispatches exactly its subscribers — O(what changed), no tree walk, no virtual DOM
301
362
  5. **MutationObserver** — Tracks dynamically added elements
302
363
 
364
+ One contract follows from #4: **an expression that should react must read reactive state.** `@[items.map(format)]` re-renders when `$.items` changes because `items` was read from `$` — but if `format` is a window global that you later *reassign*, nothing re-renders, because assigning a global is not a state write. Values that change over time belong in `$`. (Helpers defined by component scripts are safe: mounts re-settle their directives once their scripts have run.)
365
+
303
366
  ---
304
367
 
305
368
  ## Vibe Compiler
@@ -342,6 +405,7 @@ bunx vibe compile --node-modules-as-is # Copy node_modules as-is
342
405
  bunx vibe compile --components-as-is # Skip component inlining
343
406
  bunx vibe compile --runtime-as-is # Skip manifest generation
344
407
  bunx vibe compile --iterations-as-is # Skip iteration optimization
408
+ bunx vibe compile --spa # Compile the pages tree to SPA output
345
409
  ```
346
410
 
347
411
  Or via npm scripts:
@@ -377,7 +441,8 @@ Add to your `package.json`:
377
441
  "nodeModulesAsIs": false,
378
442
  "componentsAsIs": false,
379
443
  "runtimeAsIs": false,
380
- "iterationsAsIs": false
444
+ "iterationsAsIs": false,
445
+ "spa": false
381
446
  }
382
447
  }
383
448
  ```
@@ -389,6 +454,7 @@ Add to your `package.json`:
389
454
  - `componentsAsIs: false` — Inline components (default) or keep separate for runtime
390
455
  - `iterationsAsIs: false` — Optimize iterations (default) or use runtime rendering
391
456
  - `runtimeAsIs: false` — Generate manifest (default) or skip for runtime-only
457
+ - `spa: false` — Compile the pages tree to SPA output: fragments + route table + shell (see SPA Mode)
392
458
 
393
459
  **Defaults** (when no config):
394
460
 
@@ -445,6 +511,47 @@ Features:
445
511
  - **Debounced** — 300ms debounce prevents excessive compilation during rapid changes
446
512
  - **Delta output** — First compile shows full output, subsequent compiles show only changes
447
513
 
514
+ ### SPA Mode
515
+
516
+ `"spa": true` (or `--spa`) compiles the same MPA `pages/` tree into a single-page app — authors change **nothing** about how pages are written:
517
+
518
+ ```json
519
+ { "vibe-compiler": { "spa": true } }
520
+ ```
521
+
522
+ The pages tree becomes three things:
523
+
524
+ 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.
525
+ 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.
526
+ 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).
527
+
528
+ **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.
529
+
530
+ **Deployment**: one rewrite — every route serves `/index.html`; `/components/**`, `/vibe-hyperspeed/**`, and assets serve as files. The vercel.json shape:
531
+
532
+ ```json
533
+ {
534
+ "rewrites": [
535
+ { "source": "/((?!components/|vibe-hyperspeed/|nodemodules/|.*\\..*).*)", "destination": "/index.html" }
536
+ ]
537
+ }
538
+ ```
539
+
540
+ Dev-server equivalents: any "history API fallback" option (`http-server` can't rewrite; `vite preview`, `serve -s`, and Caddy `try_files` all can).
541
+
542
+ **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.
543
+
544
+ **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.
545
+
546
+ ### MPA → SPA Compliance Contract
547
+
548
+ SPA mode assumes; this contract defines. Pages that follow it compile to MPA today and SPA tomorrow with no edits:
549
+
550
+ 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'`.
551
+ 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()`.
552
+ 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.
553
+ 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).
554
+
448
555
  ### Component System
449
556
 
450
557
  Components are automatically inlined during compilation with full support for props and slots:
@@ -632,7 +739,7 @@ ISC
632
739
  - `runtime/affected.js:~122` — `shouldAffect = noMatch || ...` fallback for unmatched expressions
633
740
  - `runtime/affected.js:~248` — same fallback for name bindings
634
741
 
635
- Every time `matchesKey` can't prove an expression is unrelated to a state change, that binding re-hydrates. A proper AST-based dependency extraction at parse time would eliminate the fallback entirely: each binding stores its exact dependency set, and runtime just checks if any dep key changed.
742
+ As of 3.0.0 this is solved not by static extraction (which lies for helper calls) but by the subscription engine: every binding records the state keys it actually reads during evaluation, live at the proxy's get trap, so a write notifies exactly its subscribers. Update cost is O(change), and it costs zero authoring syntax.
636
743
 
637
744
  ### HTML lowercases attribute names — breaks name binding matching
638
745
 
package/boot.js CHANGED
@@ -25,8 +25,8 @@ export const boot = () => {
25
25
  booted = true;
26
26
 
27
27
  // Merge all state: global + components
28
- const globalState = window.__vibeGlobalState || {};
29
- const componentStates = window.__vibeComponents || {};
28
+ const globalState = window.__vibe?.state || {};
29
+ const componentStates = window.__vibe?.components || {};
30
30
 
31
31
  const mergedState = {
32
32
  ...globalState,
@@ -34,8 +34,8 @@ export const boot = () => {
34
34
  };
35
35
 
36
36
  // Get config and targetSelector (first caller wins)
37
- const config = window.__vibeConfig || {};
38
- const targetSelector = window.__vibeTargetSelector || '';
37
+ const config = window.__vibe?.config || {};
38
+ const targetSelector = config.target || '';
39
39
 
40
40
  // Boot with merged state
41
41
  window.$ = main(mergedState, config, targetSelector);
package/component.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // <component>
4
4
  // <script type="module">
5
5
  // import component from 'vibe/component.js';
6
- // component({ count: 0 }, { debug: false }, 'body');
6
+ // component({ count: 0 }, { debug: false, target: 'body' });
7
7
  // </script>
8
8
  // <div>@[this.count]</div>
9
9
  // </component>
@@ -14,28 +14,31 @@
14
14
  import { generateComponentId } from './runtime/component.js';
15
15
  import { ensureBoot } from './boot.js';
16
16
 
17
- const component = (state = {}, config, targetSelector) => {
17
+ const component = (state = {}, config) => {
18
18
  // Initialize component registry
19
- if (!window.__vibeComponents) {
20
- window.__vibeComponents = {};
21
- }
19
+ const ns = (window.__vibe ??= {});
20
+ if (!ns.components) ns.components = {};
22
21
 
23
- // Find the first unprocessed INLINE component wrapper
24
- // Skip <component src=""> (fetched components) - they don't need state tagging
25
- // Scripts execute in DOM order, so we claim wrappers in DOM order too
26
- // Supports: <component> or <div class="component">
22
+ // Pair this call with its wrapper. Module scripts execute in document
23
+ // order, so the K-th component() call belongs to the K-th wrapper whose own
24
+ // direct <script type="module"> calls component( the runtime mirror of
25
+ // the build tagger's registers_state predicate. Wrappers without such a
26
+ // script (no local state, or a compiler-neutered vibe-module script that
27
+ // registers through boot) can never claim a call, so they're excluded —
28
+ // otherwise a stateless wrapper earlier in the document absorbs a later
29
+ // section's claim and every pairing after it is cross-wired. In compiled
30
+ // pages the claimed wrapper is already build-tagged: register under its id.
31
+ // Supports: <component> or <div class="component">; <component src> mounts
32
+ // register through the fetch pipeline instead.
27
33
  const allWrappers = Array.from(document.querySelectorAll('component:not([src]), div.component:not([src])'));
28
-
29
- // Try to find unprocessed component (runtime mode)
30
- let wrapper = allWrappers.find(el => !el.hasAttribute('data-vibe-component-id'));
31
-
32
- // If not found, try to find pre-compiled component that hasn't registered state yet (compiled mode)
33
- if (!wrapper) {
34
- wrapper = allWrappers.find(el => {
35
- const existingId = el.getAttribute('data-vibe-component-id');
36
- return existingId && !window.__vibeComponents[existingId];
37
- });
38
- }
34
+ const wrapper = allWrappers.find((el) => {
35
+ const ownsCall = Array.from(el.children).some(
36
+ (child) => child.matches?.('script[type="module"]') && /component\s*\(/.test(child.textContent),
37
+ );
38
+ if (!ownsCall) return false;
39
+ const existingId = el.getAttribute('data-vibe-component-id');
40
+ return !existingId || !ns.components[existingId];
41
+ });
39
42
 
40
43
  if (!wrapper) {
41
44
  console.warn('[vibe] component() must be called inside <component> or <div class="component">');
@@ -50,16 +53,11 @@ const component = (state = {}, config, targetSelector) => {
50
53
  }
51
54
 
52
55
  // Register component state in shared registry
53
- window.__vibeComponents[componentId] = state;
54
-
55
- // Store config (first caller wins)
56
- if (config && !window.__vibeConfig) {
57
- window.__vibeConfig = config;
58
- }
56
+ ns.components[componentId] = state;
59
57
 
60
- // Store targetSelector (first caller wins)
61
- if (targetSelector && !window.__vibeTargetSelector) {
62
- window.__vibeTargetSelector = targetSelector;
58
+ // Store config (first caller wins); config.target scopes the boot.
59
+ if (config && !ns.config) {
60
+ ns.config = config;
63
61
  }
64
62
 
65
63
  // Ensure boot happens
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
8
8
  // from Vibe, and deleting it leaves a working framework. It depends solely on
9
- // the runtime global `window.$` (reconcile / renderComponent /
9
+ // the runtime global `window.$` (reconcile / _renderComponent /
10
10
  // clearComponentCache), so it ships as a flat module a plain static server can
11
11
  // serve as-is — no bundler, no Vite.
12
12
  //
@@ -267,7 +267,7 @@ export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
267
267
  // Component file changed. Strategy:
268
268
  // 1. Fetch the raw template once per update; hash its <script type="module">
269
269
  // contents. Script hash unchanged + not inside an iteration → surgical
270
- // path: $.renderComponent produces the processed HTML (props + slot
270
+ // path: $._renderComponent produces the processed HTML (props + slot
271
271
  // substituted, componentIds reused) and $.reconcile diffs it against
272
272
  // the live wrapper's children. DOM identity, focus, and component
273
273
  // state are preserved.
@@ -295,7 +295,7 @@ export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
295
295
  const rawUrl = path + (path.includes('?') ? '&' : '?') + '_t=' + Date.now();
296
296
  const rawHtml = await fetch(rawUrl, { cache: 'no-store' }).then((r) => r.text());
297
297
  const scriptHash = hashScripts(rawHtml);
298
- const canSurgical = typeof window.$?.renderComponent === 'function'
298
+ const canSurgical = typeof window.$?._renderComponent === 'function'
299
299
  && typeof window.$?.reconcile === 'function';
300
300
 
301
301
  let surgical = 0;
@@ -314,7 +314,7 @@ export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
314
314
  ? el._vibePluginSlot
315
315
  : (el._vibeSlotContent || '');
316
316
  const props = el._vibeProps || {};
317
- const processedHtml = window.$.renderComponent(rawHtml, props, slot, { componentIds });
317
+ const processedHtml = window.$._renderComponent(rawHtml, props, slot, { componentIds });
318
318
  const summary = await window.$.reconcile(el, processedHtml);
319
319
  el._vibeScriptHash = scriptHash;
320
320
  el._vibeRawSource = rawHtml;
package/index.js CHANGED
@@ -31,10 +31,24 @@ const createVibeInstance = () => ({
31
31
  }
32
32
  });
33
33
 
34
- const vibe = (state = {}, config, targetSelector) => {
34
+ // Shallow, key-level defaults: set only keys `target` does not have yet.
35
+ // This is what "initial state, declared again" means once the app is live —
36
+ // a re-mounted SPA page fragment's vibe({ ... }) seeds on first mount and
37
+ // never clobbers live state after (vibe() state is app-lifetime; per-visit
38
+ // state belongs in a component()).
39
+ export const applyDefaults = (target, state) => {
40
+ for (const key in state) {
41
+ if (!(key in target)) target[key] = state[key];
42
+ }
43
+ return target;
44
+ };
45
+
46
+ const vibe = (state = {}, config) => {
35
47
  if (isBooted()) {
36
- // Already booted - merge state into live proxy
37
- Object.assign(window.$, state);
48
+ // Already booted: initial state declared late seeds missing keys only —
49
+ // on a fresh document load this branch never runs, so MPA behavior is
50
+ // byte-identical.
51
+ applyDefaults(window.$, state);
38
52
  return window.$;
39
53
  }
40
54
 
@@ -43,24 +57,19 @@ const vibe = (state = {}, config, targetSelector) => {
43
57
  vibeInstance = createVibeInstance();
44
58
  }
45
59
 
46
- // Not booted yet - accumulate in global state registry
47
- if (!window.__vibeGlobalState) {
48
- window.__vibeGlobalState = {};
49
- }
50
- Object.assign(window.__vibeGlobalState, state);
51
-
52
- // Store config (first caller wins)
53
- if (config && !window.__vibeConfig) {
54
- window.__vibeConfig = config;
55
- }
60
+ // Not booted yet - accumulate in the reserved namespace's state registry
61
+ const ns = (window.__vibe ??= {});
62
+ if (!ns.state) ns.state = {};
63
+ Object.assign(ns.state, state);
56
64
 
57
- // Store targetSelector (first caller wins)
58
- if (targetSelector && !window.__vibeTargetSelector) {
59
- window.__vibeTargetSelector = targetSelector;
65
+ // Store config (first caller wins). `config.target` scopes the boot to a
66
+ // selector (the old third positional argument, folded into config at 3.0.0).
67
+ if (config && !ns.config) {
68
+ ns.config = config;
60
69
  }
61
70
 
62
71
  // Explicit boot call (no state passed means "boot now with accumulated state")
63
- if (Object.keys(state).length === 0 && Object.keys(window.__vibeGlobalState).length > 0) {
72
+ if (Object.keys(state).length === 0 && Object.keys(ns.state).length > 0) {
64
73
  return boot();
65
74
  }
66
75
 
package/llms.txt CHANGED
@@ -88,6 +88,8 @@ $.config.theme.colors.primary = "#007bff";
88
88
 
89
89
  Vibe uses recursive proxies to detect changes at any nesting level automatically.
90
90
 
91
+ **Reactivity model (3.0.0):** every binding, conditional, and iteration subscribes to the state keys its expression read during evaluation; a state write dispatches exactly its subscribers. Consequence: an expression only re-evaluates when reactive state it read changes. Window globals, module values, and other non-state sources are read once — reassigning them re-renders nothing. Anything that changes over time belongs in `$`. Helpers defined by component scripts are picked up automatically (mounts re-settle once their scripts have run).
92
+
91
93
  ## Control Flow
92
94
 
93
95
  ### Iteration
@@ -201,7 +203,7 @@ Multi-segment paths, conditionals (`<!-- if this.editing -->`), and iterations (
201
203
 
202
204
  Mechanism:
203
205
  1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper
204
- 2. It calls `ensureBoot()` from `@ape-egg/vibe/boot`, which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once per page, even if multiple drop-in blocks call `component()`
206
+ 2. It triggers the internal boot, which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once per page, even if multiple drop-in blocks call `component()`
205
207
  3. `@[this.X]`, `onclick="this.fn()"`, `<!-- if this.X -->`, etc. resolve against that block's bucket
206
208
 
207
209
  Multiple drop-in blocks on the same page each get their own state and run independently. They can still read each other's state via global `$['<id>']` if coordination is needed.
@@ -214,10 +216,39 @@ This makes vibe usable as a "sprinkle of reactivity" library: paste a snippet in
214
216
  $.on('ready', () => {}); // once, after initial parse + hydrate + components mounted
215
217
  $.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
216
218
  $.on('afterDomMutation', () => {}); // after every MutationObserver batch
219
+ $.on('unmount', () => {}); // scope-resolved teardown: in a component script, fires when
220
+ // THAT component unmounts; at page level, fires on pagehide
217
221
  ```
218
222
 
219
223
  Also: `await $.ready` resolves after boot, useful when calling code captured `window.$` before vibe finished initializing.
220
224
 
225
+ ## SPA Routing (`@ape-egg/vibe/spa`)
226
+
227
+ A standalone router maintaining `$.page = { path, route, params, src }`; a reactive component src is the route outlet:
228
+
229
+ ```html
230
+ <script type="module">
231
+ import vibe from '@ape-egg/vibe';
232
+ import { setupSpa, resolve } from '@ape-egg/vibe/spa';
233
+
234
+ const routes = [
235
+ { route: '/brawlers/:index', src: '/components/brawler.html', title: 'Brawler' }, // :param = one segment
236
+ { route: '/docs/:rest*', src: '/components/docs.html' }, // trailing :name* = zero or more
237
+ { route: '/', src: '/components/home.html', title: 'Home' },
238
+ { route: '*', src: '/components/lost.html' }, // no-match fallback (deep links/popstate only)
239
+ ];
240
+
241
+ window.$ = vibe({ page: resolve(location, routes) ?? {} });
242
+ setupSpa({ routes }); // returns { navigate, dispose }
243
+ </script>
244
+
245
+ <component src="@[page.src]"></component>
246
+ ```
247
+
248
+ Rules: tables are pre-sorted most-specific-first, first match wins. Clicks are claimed only for same-origin, unmodified, untargeted links whose pathname matches a real route (`'*'` never claims) — everything else navigates natively, so mixed MPA/SPA output works. Route titles swap `document.title`. `resolve(location, routes)` is pure. A custom `onNavigate` makes it a pure router without Vibe.
249
+
250
+ State semantics under SPA: `vibe()` state is app-lifetime, `component()` state is mount-lifetime (resets per visit). `vibe()` itself has defaults semantics once booted — it sets only keys that don't exist yet on `$`, so re-running page scripts (SPA fragment re-mounts) never clobber live state. The compiler's SPA mode (`"spa": true` in `vibe-compiler` config, or `--spa`) compiles an MPA `pages/` tree into page fragments under `/components/vibe-spa/`, a generated route table, and a composed `/index.html` shell wired to this router — deploy with one rewrite: every route → `/index.html`.
251
+
221
252
  ## Special Attributes
222
253
 
223
254
  ### vibe-fouc
@@ -299,15 +330,15 @@ Imported from `@ape-egg/vibe/component`. Registers a component-local state bucke
299
330
 
300
331
  ### `$.on(event, callback)`
301
332
 
302
- Register a lifecycle listener. Events: `'ready'`, `'afterUpdate'`, `'afterDomMutation'`. Listeners registered before boot are queued and replayed once vibe is ready.
333
+ Register a lifecycle listener. Events: `'ready'`, `'afterUpdate'`, `'afterDomMutation'`, `'unmount'`. Listeners registered before boot are queued and replayed once vibe is ready. A `'ready'` listener registered AFTER boot (a fetched fragment's script) fires once its mount settles — same timing as `await $.ready`. `'unmount'` is scope-resolved: inside a component script it fires on that component's unmount (and before an HMR re-run); at page level it fires on pagehide.
303
334
 
304
335
  ### `$.ready`
305
336
 
306
337
  A Promise that resolves once vibe has finished initial parse + hydrate + component loading.
307
338
 
308
- ### `$.reconcile(el, html)` and `$.renderComponent(rawHtml, props, slot, opts)` (advanced)
339
+ ### `$.reconcile(el, html)` (advanced)
309
340
 
310
- Subtree reconciliation primitives used by `@ape-egg/vite-plugin-vibe` for surgical HMR. `$.reconcile` diffs `el`'s children against fresh `html` while preserving DOM identity, focus, and selection; vibe-managed regions (iterations, conditionals, components, slot pairs) are treated as opaque. Treat these as plumbing — shape may evolve.
341
+ Subtree reconciliation used by `@ape-egg/vite-plugin-vibe` for surgical HMR: diffs `el`'s children against fresh `html` while preserving DOM identity, focus, and selection; vibe-managed regions (iterations, conditionals, components, slot pairs) are treated as opaque. Treat as plumbing — shape may evolve. (Internal renders/registration live on underscore-prefixed `$` members and are not API.)
311
342
 
312
343
  ## Scoped Variables in Iterations
313
344
 
@@ -368,7 +399,7 @@ Vibe consists of these core runtime modules (in `runtime/`):
368
399
 
369
400
  - **No computed values primitive**: derived state can be done with `Object.defineProperty($, 'x', { get })` or with `afterUpdate` listeners; a first-class `computed` API isn't shipped
370
401
  - **Expression security**: `new Function()` evaluation — don't bind untrusted input
371
- - **HTML binding**: `@[expr]` always sets `textContent` (escape-safe). A first-class opt-in for trusted HTML is planned but not finalized
402
+ - **HTML binding**: `@[expr]` always sets `textContent` (escape-safe). For trusted HTML, `@[$.unsafe(trustedHtml)]` sets `innerHTML` when it is the element's sole content (no sanitizing — trusted input only; injected markup is inert)
372
403
 
373
404
  ## Best Practices
374
405
 
package/package.json CHANGED
@@ -1,34 +1,40 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.22",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
- "description": "Runtime-first reactivity with optional compiler",
5
+ "description": "Runtime-first reactivity for plain HTML — no build step, no virtual DOM, no new syntax to learn",
6
6
  "main": "index.js",
7
7
  "homepage": "https://vibe.korte.kim",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/kkortes/vibe.git"
11
+ },
8
12
  "exports": {
9
13
  ".": "./index.js",
10
- "./boot": "./boot.js",
11
14
  "./component": "./component.js",
12
- "./hot-module-refresh": "./hot-module-refresh.js",
13
- "./runtime": "./runtime/index.js",
14
- "./compiler": "./compiler/bin/vibe-compile.js"
15
- },
16
- "bin": {
17
- "vibe": "./compiler/bin/vibe-compile.js"
15
+ "./spa": "./spa.js",
16
+ "./hot-module-refresh": "./hot-module-refresh.js"
18
17
  },
18
+ "files": [
19
+ "index.js",
20
+ "boot.js",
21
+ "component.js",
22
+ "spa.js",
23
+ "hot-module-refresh.js",
24
+ "runtime",
25
+ "vibe.css",
26
+ "llms.txt"
27
+ ],
19
28
  "keywords": [
20
29
  "reactive",
21
30
  "framework",
22
31
  "frontend",
23
32
  "ui",
33
+ "no-build",
24
34
  "mutation-observer",
25
35
  "proxy",
26
- "minimalistic",
27
- "compiler"
36
+ "minimalistic"
28
37
  ],
29
- "scripts": {
30
- "test": "echo \"Error: no test specified\" && exit 1"
31
- },
32
38
  "author": "kkortes",
33
39
  "license": "ISC",
34
40
  "publishConfig": {