@ape-egg/vibe 2.1.21 → 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.
@@ -1,4 +1,5 @@
1
1
  import { debugLog } from './debug.js';
2
+ import { shouldCleanup } from './cleanup.js';
2
3
  import {
3
4
  PHASE_FETCH,
4
5
  PHASE_FETCH_CACHED,
@@ -71,6 +72,23 @@ const createScopedDollar = (componentId) => {
71
72
  get(target, prop, receiver) {
72
73
  if (prop === 'on') {
73
74
  return (event, callback) => {
75
+ // 'unmount' is scope-resolved: in a component script it means THIS
76
+ // component's unmount (conditional toggle, iteration removal,
77
+ // reactive src swap — and before an HMR re-run of the same id).
78
+ // The callback rides the same per-component cleanup registry the
79
+ // global-event unsubscribes below ride, so a component owns
80
+ // arbitrary side effects (intervals, listeners, sockets) without
81
+ // leaking them past its lifetime. At page level the root's `on`
82
+ // resolves the same event name to pagehide instead.
83
+ if (event === 'unmount') {
84
+ if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
85
+ const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
86
+ slot.push(callback);
87
+ return () => {
88
+ const i = slot.indexOf(callback);
89
+ if (i >= 0) slot.splice(i, 1);
90
+ };
91
+ }
74
92
  const unsub = target.on(event, callback);
75
93
  if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
76
94
  const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
@@ -261,6 +279,63 @@ export const abortComponentFetch = (element) => {
261
279
  }
262
280
  };
263
281
 
282
+ // A fetched-component host: `<component>` or `<div class="component">`.
283
+ export const isComponentWrapper = (el) =>
284
+ el.nodeName === 'COMPONENT' ||
285
+ (el.nodeName === 'DIV' && el.classList?.contains('component'));
286
+
287
+ // The live element a reactive src binding acts on. The manifest tree keeps the
288
+ // ORIGINAL element, but every (re)mount replaces the wrapper (finalize's
289
+ // replaceWith), leaving a `_vibeReplacedBy` link behind. Follow the chain and
290
+ // compress it so intermediate detached wrappers stay collectable.
291
+ export const liveComponentWrapper = (element) => {
292
+ let live = element;
293
+ while (live._vibeReplacedBy) live = live._vibeReplacedBy;
294
+ if (live !== element) element._vibeReplacedBy = live;
295
+ return live;
296
+ };
297
+
298
+ // (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
299
+ // Three phases of a wrapper's life, one entry point:
300
+ // - Unprocessed element (no fetch yet): write the resolved src — the pending
301
+ // boot/observer processComponent pass fetches it.
302
+ // - Fetch in flight: abort it and fetch the new src.
303
+ // - Mounted wrapper (src consumed by finalize): re-fetch and re-mount; the
304
+ // authored props + slot content re-apply via the remount context finalize
305
+ // stashed on the wrapper, and the outgoing component's state is evicted by
306
+ // the removal pass when replaceWith drops the old wrapper.
307
+ export const remountComponent = (el, src, debug = false) => {
308
+ const wasFetching = pendingFetches.has(el);
309
+ const hadSrcAttr = el.hasAttribute('src');
310
+ // Compare against the LATEST requested src: with a fetch in flight the src
311
+ // attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
312
+ // mounted and idle, the finalize-stashed value does.
313
+ const current = wasFetching
314
+ ? el.getAttribute('src')
315
+ : (el._vibeMountedSrc ?? el.getAttribute('src'));
316
+ if (src === current) return;
317
+ abortComponentFetch(el);
318
+ el.setAttribute('src', src);
319
+ // Pre-fetch element awaiting its initial processing pass: that pass reads
320
+ // the new value — nothing to redo. A declaration-form wrapper (bound src
321
+ // that resolved to nothing, carried on data-vibe-src with no src
322
+ // attribute) was invisible to that pass, so hydration owns its first
323
+ // fetch too — the observer doesn't watch attributes.
324
+ if (el._vibeMountedSrc === undefined && !wasFetching && hadSrcAttr) return;
325
+ processSingle(el, debug);
326
+ };
327
+
328
+ // Key-change remount (`key="@[page.path]"`): mount the CURRENT src fresh even
329
+ // though it is unchanged. Only a mounted, idle wrapper has anything to redo —
330
+ // with a fetch pending the incoming mount is already fresh (a src change in
331
+ // the same flush started it), and an unmounted wrapper's first mount is owned
332
+ // by the normal processing pass.
333
+ export const forceRemount = (el, debug = false) => {
334
+ if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
335
+ el.setAttribute('src', el._vibeMountedSrc);
336
+ processSingle(el, debug);
337
+ };
338
+
264
339
  // Check if an element is nested inside another unprocessed component[src]
265
340
  const isNestedInUnprocessedComponent = (el, rootElement) => {
266
341
  let parent = el.parentElement;
@@ -473,16 +548,25 @@ const processSingle = (el, debug) => {
473
548
 
474
549
  const src = el.getAttribute('src');
475
550
 
476
- // Use pre-hydration slot content if available (saved by index.js before hydration ran),
477
- // otherwise fall back to current innerHTML (e.g. runtime-only usage without boot).
551
+ // Use pre-hydration slot content if available (saved by index.js before
552
+ // hydration ran, or re-stashed by finalize for reactive-src re-mounts),
553
+ // otherwise fall back to current innerHTML (e.g. runtime-only usage without
554
+ // boot). Kept on the element — a re-mount consumes the same authored slot.
478
555
  const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
479
- delete el._vibeSlotContent;
480
- const props = {};
481
- Array.from(el.attributes).forEach((attr) => {
482
- if (attr.name !== 'src') {
483
- props[attr.name] = attr.value;
484
- }
485
- });
556
+ // A re-mounted wrapper carries no prop attributes (finalize stripped them) —
557
+ // its authored props ride the remount context stashed at the previous mount.
558
+ let props = el._vibeRemountProps;
559
+ if (!props) {
560
+ props = {};
561
+ // `src` and `key` are the wrapper's own contract (what to mount / when to
562
+ // remount), and data-vibe-* attributes are runtime transport — none of
563
+ // them are authored props for the component.
564
+ Array.from(el.attributes).forEach((attr) => {
565
+ if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
566
+ props[attr.name] = attr.value;
567
+ }
568
+ });
569
+ }
486
570
 
487
571
  // Capture cache state before the fetch so the debug layer can tell a real
488
572
  // network fetch from a runtime-cache hit (the call below would make them
@@ -591,8 +675,10 @@ const processSingle = (el, debug) => {
591
675
  // Delegate prop substitution + slot inlining to shared helper.
592
676
  const transformedHtml = renderPropsAndSlot(temp, props, children);
593
677
 
594
- // Clean up pending fetch tracker
595
- pendingFetches.delete(el);
678
+ // Clean up pending fetch tracker — only if this fetch still owns the
679
+ // slot (a reactive-src re-mount may have aborted us and registered a
680
+ // newer controller for the same element).
681
+ if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
596
682
 
597
683
  // Replace with clean component wrapper (no src, no props)
598
684
  // Check if element still has a parent (might have been removed during fetch)
@@ -615,6 +701,27 @@ const processSingle = (el, debug) => {
615
701
  // the plugin would have nothing to compare against). Vibe itself
616
702
  // never reads this; it's purely for the plugin spy.
617
703
  newWrapper._vibeRawSource = html;
704
+ // Remount context for reactive src bindings (src="@[page.src]"):
705
+ // the mounted src (no-op detection), the authored props, and the
706
+ // authored slot content. Each re-mount consumes these and finalize
707
+ // stashes them onto the next wrapper — self-sustaining across
708
+ // arbitrarily many navigations.
709
+ newWrapper._vibeMountedSrc = src;
710
+ newWrapper._vibeRemountProps = props;
711
+ newWrapper._vibeSlotContent = children;
712
+ // The authored binding travels ON the wrapper (data-vibe-src, same
713
+ // transport idea as data-vibe-namebind): the original tree node is
714
+ // pruned when this replaceWith's removal mutation is processed, and
715
+ // the replacement's reparse recaptures the binding from this
716
+ // attribute — the DOM alone carries the knowledge across swaps.
717
+ const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
718
+ if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
719
+ // The key binding and its last resolved value ride along the same
720
+ // way, so a later key change still finds what to compare against
721
+ // on the replacement wrapper.
722
+ const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
723
+ if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
724
+ if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
618
725
  // Transfer iteration-prop registry ownership from the soon-to-be-
619
726
  // detached `<component src>` to the new wrapper. The detached element
620
727
  // would otherwise trigger releaseOrphanedIterationProps and free the
@@ -650,13 +757,37 @@ const processSingle = (el, debug) => {
650
757
  // renderAllConditionals/Iterations populated runtime data) is what
651
758
  // iterate.js's update path uses to re-evaluate inlined bindings on
652
759
  // each row update.
760
+ // The observer hydrates the inserted subtree in its NEXT batch —
761
+ // until then, selectors keyed on hydrated attributes (a name-bound
762
+ // <page @[page.name]> → page[pvp] rules) don't match and the
763
+ // content paints unstyled. Cover the gap with the same fouc
764
+ // contract pages use: hidden at insertion, revealed by the batch
765
+ // that parsed and hydrated this subtree.
766
+ newWrapper.setAttribute('vibe-fouc', '');
653
767
  el._vibeReplacedBy = newWrapper;
654
768
  el.replaceWith(newWrapper);
655
769
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
656
770
 
771
+ // Build-inlined child components (compiled SPA fragments) arrive
772
+ // with tagged wrapper ids and vibe-module scripts — the compiled-
773
+ // document form. A fetched mount is the fourth delivery mode after
774
+ // boot, conditional branches, and iteration rows: run those
775
+ // scripts now so each child's component({...}) state registers
776
+ // under its build-tagged id and the _cN bindings hydrate.
777
+ executeCompiledComponentScriptsIn([newWrapper]);
778
+
657
779
  // MutationObserver handles parsing and hydrating the new content.
658
780
  // Branch nodes are registered in the manifest by mountBranch,
659
781
  // so the observer can find parents even inside conditional branches.
782
+ // Hydration can span multiple batches (nested fetched components,
783
+ // async scripts) with paints in between — reveal only when the
784
+ // subtree has settled (same predicate the page-level ready uses).
785
+ // A wrapper unmounted mid-hydration releases the hook.
786
+ const unfouc = window.$.on('afterDomMutation', () => {
787
+ if (newWrapper.isConnected && !shouldCleanup(newWrapper)) return;
788
+ newWrapper.removeAttribute('vibe-fouc');
789
+ unfouc();
790
+ });
660
791
  } else {
661
792
  // Element was detached before finalize ran (conditional unmounted
662
793
  // during fetch, parent removed, etc). Release any state component()
@@ -676,10 +807,10 @@ const processSingle = (el, debug) => {
676
807
  finalize();
677
808
  })
678
809
  .catch((error) => {
679
- // Clean up pending fetch tracker
680
- pendingFetches.delete(el);
810
+ // Clean up pending fetch tracker — ownership-guarded (see finalize)
811
+ if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
681
812
 
682
- // If fetch was aborted (element removed), silently skip
813
+ // If fetch was aborted (element removed or re-mounted), silently skip
683
814
  if (error.name === 'AbortError') {
684
815
  return;
685
816
  }
@@ -1,5 +1,6 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional, managedNodes } from './conditionals.js';
3
+ import { isComponentWrapper, liveComponentWrapper, remountComponent, forceRemount } from './component.js';
3
4
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
5
  import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
5
6
  import { RawHtml } from './raw-html.js';
@@ -82,6 +83,51 @@ export default (affected, state, manifest = {}, oldState = {}) => {
82
83
  if (aff.type === 'attribute') {
83
84
  const { attrName, attrValue, element } = aff;
84
85
  try {
86
+ // Reactive component src (`<component src="@[page.src]">`): resolve
87
+ // the binding and (re)mount through component.js. The binding rides
88
+ // along so finalize can stamp it onto the replacement wrapper
89
+ // (data-vibe-src) — the tree node holding it is pruned when the
90
+ // wrapper swap's removal mutation lands, and the fresh wrapper's
91
+ // reparse rebuilds the knowledge from that attribute. A state change
92
+ // landing inside the swap window still resolves through the
93
+ // replacement chain.
94
+ // Keyed component (`<component src="@[page.src]" key="@[page.path]">`):
95
+ // a key change is a declared identity change — remount the mounted
96
+ // component even when the src is unchanged (param→param navigation on
97
+ // the same route). The first resolution just records the initial key;
98
+ // the mount itself is owned by the normal component pass.
99
+ if (attrName === 'key' && isComponentWrapper(element)) {
100
+ const live = liveComponentWrapper(element);
101
+ const newKey = attrValue.replace(BINDING_REGEX, (_, expr) =>
102
+ evalInScope(expr, effectiveState, live) ?? '',
103
+ );
104
+ live._vibeKeyBinding = attrValue;
105
+ const prevKey = live._vibeMountedKey;
106
+ live._vibeMountedKey = newKey;
107
+ if (prevKey !== undefined && newKey !== prevKey) forceRemount(live);
108
+ return;
109
+ }
110
+
111
+ if (attrName === 'src' && isComponentWrapper(element)) {
112
+ const live = liveComponentWrapper(element);
113
+ const newSrc = attrValue.replace(BINDING_REGEX, (_, expr) =>
114
+ evalInScope(expr, effectiveState, live) ?? '',
115
+ );
116
+ live._vibeSrcBinding = attrValue;
117
+ if (newSrc) {
118
+ remountComponent(live, newSrc);
119
+ } else if (live.hasAttribute('src')) {
120
+ // Unresolved src mounts nothing (a no-match deep link leaves
121
+ // $.page.src unset): move the binding onto data-vibe-src — the
122
+ // established transport parse.js already reads — and drop the
123
+ // fetchable src, so component processing and cleanup treat the
124
+ // outlet as settled instead of fetching a stringified binding.
125
+ live.setAttribute('data-vibe-src', attrValue);
126
+ live.removeAttribute('src');
127
+ }
128
+ return;
129
+ }
130
+
85
131
  // Check if this is a pure binding (e.g., value="@[inputValue]")
86
132
  const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
87
133
  const isDomProperty = DOM_PROPERTIES.includes(attrName);
package/runtime/index.js CHANGED
@@ -568,8 +568,26 @@ const main = (s, config = {}, stringSelector = '') => {
568
568
  afterUpdate: [],
569
569
  afterDomMutation: [],
570
570
  ready: [],
571
+ unmount: [],
571
572
  };
572
573
 
574
+ // The ready phase happens once. A listener registered after it fires
575
+ // immediately (parity with the late-safe $.ready promise) — late
576
+ // registration is the SPA norm, where fragment scripts run on mount,
577
+ // long after the shell booted.
578
+ let readyFired = false;
579
+
580
+ // Page-scope 'unmount': the visitor actually leaving — pagehide (navigation
581
+ // away, tab close). Deliberately NOT visibilitychange: a tab switch is not
582
+ // an unmount, the visitor comes back. Inside a component script the same
583
+ // event name resolves to that component's unmount instead (the scoped `$`
584
+ // proxy in component.js intercepts it before it reaches this hook).
585
+ if (typeof window !== 'undefined') {
586
+ window.addEventListener('pagehide', () => {
587
+ hooks.unmount.forEach((callback) => callback());
588
+ });
589
+ }
590
+
573
591
  // Extract plain values from proxy (removes proxy wrappers)
574
592
  // Optimized: indexed loops, Object.keys (no prototype walk), inline primitive check
575
593
  const extractPlainValue = (obj) => {
@@ -677,6 +695,14 @@ const main = (s, config = {}, stringSelector = '') => {
677
695
  // snapshots or Object.keys($).
678
696
  Object.defineProperty($, 'on', {
679
697
  value: (event, callback) => {
698
+ if (event === 'ready' && readyFired) {
699
+ try {
700
+ callback();
701
+ } catch (error) {
702
+ console.error('[vibe] Error in ready hook:', error);
703
+ }
704
+ return () => {};
705
+ }
680
706
  if (hooks[event]) {
681
707
  hooks[event].push(callback);
682
708
  }
@@ -1221,6 +1247,7 @@ const main = (s, config = {}, stringSelector = '') => {
1221
1247
  cleanupExecuted = true;
1222
1248
 
1223
1249
  // Fire ready hook after cleanup completes
1250
+ readyFired = true;
1224
1251
  hooks.ready.forEach((callback) => {
1225
1252
  try {
1226
1253
  callback();
package/runtime/parse.js CHANGED
@@ -21,16 +21,36 @@ const findComponentIdForElement = (element) => {
21
21
  // Single source of truth for reading attribute/name bindings off an element.
22
22
  // Called from both the root handler and recursive() so they can't drift. Any
23
23
  // element classified as a fetched component (`<component src>` or
24
- // `<div class="component" src>`) returns nulls its attributes are props
25
- // owned by processComponent and must stay raw; hydrating them would coerce
26
- // objects to "[object Object]" or strip boolean-like attrs to empty.
24
+ // `<div class="component" src>`) captures ONLY a bound src (`src="@[page.src]"`
25
+ // resolved by hydration before the fetch, re-mounted on change) and a bound
26
+ // key (`key="@[page.path]"` a key change remounts the same src); every other
27
+ // attribute is a prop owned by processComponent and must stay raw — hydrating
28
+ // them would coerce objects to "[object Object]" or strip boolean-like attrs
29
+ // to empty. A mounted wrapper carries the authored bindings in data-vibe-src /
30
+ // data-vibe-key (stamped by finalize — the src attribute was consumed by the
31
+ // fetch), so the knowledge survives every wrapper replacement: reparsing the
32
+ // live DOM alone rebuilds it.
27
33
  const captureAttributeBindings = (element, aliasSet) => {
28
34
  const nodeName = element.nodeName;
29
35
  const isFetchedComponent =
30
36
  (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
31
- element.hasAttribute?.('src');
37
+ (element.hasAttribute?.('src') || element.hasAttribute?.('data-vibe-src'));
32
38
 
33
- if (isFetchedComponent || !element.attributes || element.attributes.length === 0) {
39
+ if (isFetchedComponent) {
40
+ const src = element.getAttribute?.('data-vibe-src') ?? element.getAttribute?.('src');
41
+ const key = element.getAttribute?.('data-vibe-key') ?? element.getAttribute?.('key');
42
+ const attributes = {};
43
+ BINDING_REGEX.lastIndex = 0;
44
+ if (BINDING_REGEX.test(src)) attributes.src = src;
45
+ BINDING_REGEX.lastIndex = 0;
46
+ if (key && BINDING_REGEX.test(key)) attributes.key = key;
47
+ return {
48
+ attributes: Object.keys(attributes).length ? attributes : null,
49
+ nameBindings: null,
50
+ };
51
+ }
52
+
53
+ if (!element.attributes || element.attributes.length === 0) {
34
54
  return { attributes: null, nameBindings: null };
35
55
  }
36
56
 
@@ -169,7 +169,24 @@ export const buildManifestCandidatePaths = (pathname, route) => {
169
169
  // sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
170
170
  // 404 probing. Skipped entirely when no route is declared.
171
171
  const routeSegments = route ? route.split("/").filter((s) => s) : null;
172
- if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
172
+ const isCatchAll =
173
+ routeSegments &&
174
+ routeSegments[routeSegments.length - 1]?.startsWith(":") &&
175
+ routeSegments[routeSegments.length - 1]?.endsWith("*");
176
+ if (isCatchAll) {
177
+ // A trailing `:name*` catch-all (pages/x/$$name.html) swallows every
178
+ // remaining URL segment — zero or more — so tokenizing by position is
179
+ // meaningless past the static prefix. The compiler collapses the whole
180
+ // `$$name.html` file to the same `$` token as a single `$param`, giving
181
+ // ONE manifest for every depth: <static-prefix>/$.html.manifest.js.
182
+ // Built from the raw pathname: with zero extra segments the .html
183
+ // normalization above has already mutated the prefix's last segment.
184
+ const rawSegments = pathname.split("/").filter((s) => s);
185
+ const prefix = rawSegments
186
+ .slice(0, routeSegments.length - 1)
187
+ .map((seg, i) => (routeSegments[i].startsWith(":") ? "$" : seg));
188
+ possiblePaths.push(`/vibe-hyperspeed/${[...prefix, "$"].join("/")}.html.manifest.js`);
189
+ } else if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
173
190
  const tokenized = pathSegments.map((seg, i) => {
174
191
  if (!routeSegments[i]?.startsWith(":")) return seg;
175
192
  const dot = seg.indexOf(".");
package/spa.js ADDED
@@ -0,0 +1,143 @@
1
+ // @ape-egg/vibe/spa
2
+ //
3
+ // Standalone SPA router — the tier between a handrolled router and compiler
4
+ // SPA mode. Maintains one contract: $.page = { path, route, params, src,
5
+ // name }, which a reactive component src (<component src="@[page.src]">)
6
+ // turns into a route outlet.
7
+ //
8
+ // SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
9
+ // from Vibe, and deleting it leaves a working framework. The default
10
+ // onNavigate writes to the runtime global window.$ at call time; pass a
11
+ // custom onNavigate and the module is a pure router (parse/claim/history)
12
+ // with no Vibe in sight.
13
+ //
14
+ // Route grammar (shared with the compiler's route table and route scanners):
15
+ // /static/path literal segments
16
+ // /brawlers/:index :param captures one segment
17
+ // /docs/:rest* trailing :name* captures zero or more segments
18
+ // * declared no-match fallback — deep links and popstate
19
+ // only, never claims a click
20
+ // Tables are pre-sorted most-specific-first; first match wins.
21
+
22
+ // Match one pathname against one route template. Returns the params object
23
+ // (possibly empty) on match, null otherwise. Empty segments are dropped, so
24
+ // trailing slashes resolve to the same route.
25
+ const matchRoute = (path, route) => {
26
+ const routeSegments = route.split('/').filter(Boolean);
27
+ const pathSegments = path.split('/').filter(Boolean);
28
+ const params = {};
29
+ for (let i = 0; i < routeSegments.length; i++) {
30
+ const segment = routeSegments[i];
31
+ if (segment.startsWith(':') && segment.endsWith('*')) {
32
+ params[segment.slice(1, -1)] = pathSegments.slice(i).join('/');
33
+ return params;
34
+ }
35
+ if (pathSegments[i] === undefined) return null;
36
+ if (segment.startsWith(':')) params[segment.slice(1)] = pathSegments[i];
37
+ else if (segment !== pathSegments[i]) return null;
38
+ }
39
+ return pathSegments.length === routeSegments.length ? params : null;
40
+ };
41
+
42
+ // Route slug: segments joined with dashes, params flattened to their bare
43
+ // name — '/' → 'home', '/pve/:id' → 'pve-id', '/docs/:rest*' → 'docs-rest'.
44
+ // Stable across param values, so markup hangs page-scoped attributes and
45
+ // active checks on it: <page @[page.name]>, page.name.startsWith('pve').
46
+ const routeName = (route) =>
47
+ route
48
+ .replace(/^\/+|\/+$/g, '')
49
+ .replace(/:(\w+)\*?/g, '$1')
50
+ .replace(/\//g, '-') || 'home';
51
+
52
+ // Resolve a location (anything with a .pathname, or a bare path string)
53
+ // against a route table → { path, route, params, src, name, title? } | null.
54
+ // Pure: the compiled shell seeds initial $.page with it, unit tests drive it
55
+ // directly. '*' is skipped during matching and applied only when nothing
56
+ // real matched — it is a declared fallback, not a positional route.
57
+ export const resolve = (location, routes) => {
58
+ const path = location.pathname ?? location;
59
+ for (const entry of routes) {
60
+ if (entry.route === '*') continue;
61
+ const params = matchRoute(path, entry.route);
62
+ if (params)
63
+ return {
64
+ path,
65
+ route: entry.route,
66
+ params,
67
+ src: entry.src,
68
+ name: routeName(entry.route),
69
+ title: entry.title,
70
+ };
71
+ }
72
+ const fallback = routes.find((entry) => entry.route === '*');
73
+ return fallback
74
+ ? { path, route: '*', params: {}, src: fallback.src, name: '*', title: fallback.title }
75
+ : null;
76
+ };
77
+
78
+ // Wire the router: one document-level click listener + popstate. Claiming
79
+ // rule: same-origin, unmodified, untargeted clicks whose pathname matches a
80
+ // REAL route — '*' never claims, so unrouted paths navigate natively (which
81
+ // is what makes mixed MPA/SPA output work). Returns { navigate, dispose }.
82
+ export const setupSpa = ({ routes, onNavigate }) => {
83
+ // Default Vibe binding: one fresh-object assignment so bindings diff
84
+ // cleanly, reading window.$ at call time (page scripts boot vibe before
85
+ // setupSpa runs). A route-supplied title swaps document.title.
86
+ const apply = onNavigate ?? (({ path, route, params, src, name, title }) => {
87
+ window.$.page = { path, route, params, src, name };
88
+ if (title) document.title = title;
89
+ });
90
+
91
+ const claim = (target) => {
92
+ const resolved = resolve(target, routes);
93
+ return resolved && resolved.route !== '*' ? resolved : null;
94
+ };
95
+
96
+ const push = (url, resolved) => {
97
+ history.pushState({}, '', url);
98
+ apply(resolved);
99
+ scrollTo(0, 0);
100
+ };
101
+
102
+ const onClick = (event) => {
103
+ const anchor = event.target.closest('a[href]');
104
+ if (!anchor || anchor.origin !== location.origin) return;
105
+ if (anchor.target || anchor.hasAttribute('download')) return;
106
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button) return;
107
+ if (event.defaultPrevented) return;
108
+ // Same-page hash links keep the native anchor jump.
109
+ if (anchor.hash && anchor.pathname === location.pathname) return;
110
+ const resolved = claim(anchor);
111
+ if (!resolved) return;
112
+ event.preventDefault();
113
+ push(anchor.href, resolved);
114
+ };
115
+
116
+ // History traversal re-resolves WITH the '*' fallback — symmetric with
117
+ // deep-link entry, whose URL may itself be a '*' page. No scroll: the
118
+ // browser restores scroll position on popstate.
119
+ const onPopstate = () => {
120
+ const resolved = resolve(location, routes);
121
+ if (resolved) apply(resolved);
122
+ };
123
+
124
+ // Programmatic navigation, claiming like a link click: real route match →
125
+ // SPA navigation, anything else → native load (the server rewrite serves
126
+ // the shell, whose deep-link resolution may then mount '*').
127
+ const navigate = (path) => {
128
+ const url = new URL(path, location.origin);
129
+ const resolved = claim(url);
130
+ if (resolved) push(url, resolved);
131
+ else location.assign(url);
132
+ };
133
+
134
+ document.addEventListener('click', onClick);
135
+ addEventListener('popstate', onPopstate);
136
+
137
+ const dispose = () => {
138
+ document.removeEventListener('click', onClick);
139
+ removeEventListener('popstate', onPopstate);
140
+ };
141
+
142
+ return { navigate, dispose };
143
+ };