@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.
- package/CHANGELOG.md +49 -0
- package/README.md +98 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +459 -9
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/spa.rs +477 -0
- package/compiler/src/compiler/watcher.rs +182 -20
- package/compiler/src/config.rs +41 -1
- package/compiler/src/main.rs +12 -1
- package/index.js +17 -3
- package/llms.txt +29 -0
- package/package.json +2 -1
- package/runtime/component.js +145 -14
- package/runtime/hydrate.js +46 -0
- package/runtime/index.js +27 -0
- package/runtime/parse.js +25 -5
- package/runtime/pre-compiled-manifest.js +18 -1
- package/spa.js +143 -0
package/runtime/component.js
CHANGED
|
@@ -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
|
|
477
|
-
//
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
}
|
package/runtime/hydrate.js
CHANGED
|
@@ -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>`)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|