@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.
- package/README.md +112 -5
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +26 -17
- package/llms.txt +36 -5
- package/package.json +20 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +360 -98
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +277 -110
- package/runtime/index.js +189 -65
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +81 -11
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +206 -0
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1159
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2522
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -15
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1147
- package/compiler/src/config.rs +0 -239
- package/compiler/src/main.rs +0 -347
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- package/test-results/.last-run.json +0 -4
package/README.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version
|
|
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
|
|
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. **
|
|
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
|
-
|
|
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.
|
|
29
|
-
const componentStates = window.
|
|
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.
|
|
38
|
-
const targetSelector =
|
|
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
|
|
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
|
|
17
|
+
const component = (state = {}, config) => {
|
|
18
18
|
// Initialize component registry
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
19
|
+
const ns = (window.__vibe ??= {});
|
|
20
|
+
if (!ns.components) ns.components = {};
|
|
22
21
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
62
|
-
|
|
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
|
package/hot-module-refresh.js
CHANGED
|
@@ -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 /
|
|
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: $.
|
|
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.$?.
|
|
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.$.
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
|
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)`
|
|
339
|
+
### `$.reconcile(el, html)` (advanced)
|
|
309
340
|
|
|
310
|
-
Subtree reconciliation
|
|
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).
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Runtime-first reactivity
|
|
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
|
-
"./
|
|
13
|
-
"./
|
|
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": {
|