@ape-egg/vibe 2.1.22 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
package/runtime/utils.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { THIS_PROP_REGEX } from './constants.js';
2
+ import { recordRead, recordAbsentRead } from './tracking.js';
3
+ import { reportEvalError } from './debug.js';
2
4
 
3
5
  // Fast incrementing counter instead of expensive random hash
4
6
  let hashCounter = 0;
@@ -25,6 +27,29 @@ export const rememberScopedKeys = (state, keys, overlay) => {
25
27
  export const ownKeysOf = (state) => scopedKeys.get(state) || Object.keys(state);
26
28
  export const scopedOverlayOf = (state) => scopedOverlay.get(state);
27
29
 
30
+ // Register just the overlay for a merged PLAIN row snapshot (affected's
31
+ // iteration descent builds those without going through createScopedState).
32
+ // Tracking uses it to tell alias reads from global reads.
33
+ export const rememberOverlay = (state, overlay) => {
34
+ if (overlay) scopedOverlay.set(state, overlay);
35
+ return state;
36
+ };
37
+
38
+ // The overlay's key set, cached per state object — the skipKeys a tracking
39
+ // window needs so loop-alias reads don't subscribe.
40
+ const overlayKeySets = new WeakMap();
41
+ export const overlayKeysOf = (state) => {
42
+ if (state === null || typeof state !== 'object') return null;
43
+ const overlay = scopedOverlay.get(state);
44
+ if (!overlay) return null;
45
+ let set = overlayKeySets.get(state);
46
+ if (!set) {
47
+ set = new Set(Object.keys(overlay));
48
+ overlayKeySets.set(state, set);
49
+ }
50
+ return set;
51
+ };
52
+
28
53
  // The live root reactive proxy, wired once at boot by index.js. Used ONLY to
29
54
  // reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
30
55
  // for state reads.
@@ -61,7 +86,12 @@ const dollarFor = (state) => {
61
86
  let wrapped = dollarCache.get(state);
62
87
  if (!wrapped) {
63
88
  wrapped = new Proxy(state, {
64
- get: (t, k) => (k in t ? t[k] : rootHelper(k) ? rootProxy[k] : undefined),
89
+ // A `$.key` read on a plain snapshot never touches the live proxy, so
90
+ // the tracking trap can't see it — record it here. The helper fallback
91
+ // isn't a state read; a genuine miss records absent-tier so the key's
92
+ // later creation dispatches this subscriber.
93
+ get: (t, k) =>
94
+ k in t ? (recordRead(k), t[k]) : rootHelper(k) ? rootProxy[k] : (recordAbsentRead(k), undefined),
65
95
  has: (t, k) => k in t || rootHelper(k),
66
96
  });
67
97
  dollarCache.set(state, wrapped);
@@ -69,9 +99,81 @@ const dollarFor = (state) => {
69
99
  return wrapped;
70
100
  };
71
101
 
72
- // Function compilation cache: avoids creating new Function() for repeated expressions
73
- // Key: normalized expression + '\0' + state keys joined by '\0'
102
+ // Function compilation cache: avoids creating new Function() for repeated
103
+ // expressions. Keyed by the CANONICALIZED expression alone params derive
104
+ // from the expression's own free identifiers (state-first, live global
105
+ // fallback at call time), and per-mount component ids (`_cN`) canonicalize
106
+ // into parameters — so the cache is bounded by the app's distinct template
107
+ // expressions, not by state shape or mount count. The previous key included
108
+ // the full state-key signature: every SPA navigation mints fresh `_cN` keys,
109
+ // so each navigation recompiled every expression and left the dead entries
110
+ // cached forever (~1,300 per navigation cycle in the game).
74
111
  const fnCache = new Map();
112
+ export const evalCacheSize = () => fnCache.size;
113
+
114
+ // Canonicalize + compile one normalized expression. One regex pass: skips
115
+ // string literals and object-literal keys, folds `$['_cN']` and bare `_cN`
116
+ // references into `$[__vibeCidK]` parameters, and collects every remaining
117
+ // free identifier (minus locals declared by arrow params, reserved words,
118
+ // and known globals) as a parameter of the compiled function.
119
+ const compileExpression = (normalized) => {
120
+ // Arrow-function parameters are locally bound — never parameters of ours.
121
+ const locals = new Set();
122
+ normalized.replace(/\(([^()]*)\)\s*=>/g, (_, paramList) => {
123
+ for (const p of paramList.split(',')) {
124
+ const name = p.trim().split('=')[0].trim();
125
+ if (/^[a-zA-Z_$][\w$]*$/.test(name)) locals.add(name);
126
+ }
127
+ return '';
128
+ });
129
+ const singleParamRe = /(?:^|[^\w$.])([a-zA-Z_$][\w$]*)\s*=>/g;
130
+ let paramMatch;
131
+ while ((paramMatch = singleParamRe.exec(normalized)) !== null) {
132
+ locals.add(paramMatch[1]);
133
+ }
134
+
135
+ const params = [];
136
+ const paramSet = new Set();
137
+ const cids = [];
138
+ const cidParams = new Map();
139
+ const cidParamFor = (id) => {
140
+ let p = cidParams.get(id);
141
+ if (!p) {
142
+ p = `__vibeCid${cids.length}`;
143
+ cidParams.set(id, p);
144
+ cids.push(id);
145
+ }
146
+ return p;
147
+ };
148
+
149
+ const canonical = normalized.replace(
150
+ /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|\$\['(_c\d+)'\]|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*/g,
151
+ (match, strLit, bracketCid, objKey) => {
152
+ if (strLit !== undefined || objKey !== undefined) return match;
153
+ if (bracketCid !== undefined) return `$[${cidParamFor(bracketCid)}]`;
154
+ if (/^_c\d+$/.test(match)) return `$[${cidParamFor(match)}]`;
155
+ if (match === '$' || locals.has(match) || EVAL_IDENT_EXCLUDE.has(match)) return match;
156
+ if (!paramSet.has(match)) {
157
+ paramSet.add(match);
158
+ params.push(match);
159
+ }
160
+ return match;
161
+ },
162
+ );
163
+
164
+ let entry = fnCache.get(canonical);
165
+ if (!entry) {
166
+ entry = {
167
+ fn: new Function(...params, ...cidParams.values(), '$', `'use strict'; return (${canonical})`),
168
+ params,
169
+ };
170
+ fnCache.set(canonical, entry);
171
+ }
172
+ // Params/cids are derived deterministically from the expression text, so a
173
+ // cache hit from a DIFFERENT original spelling (another mount's `_cN`)
174
+ // reuses the compiled fn with THIS call's ids.
175
+ return { fn: entry.fn, params: entry.params, cids };
176
+ };
75
177
 
76
178
  // Built-ins and reserved words that must NEVER be pre-declared as `var`
77
179
  // inside a compiled expression — they're either real globals we want to
@@ -79,12 +181,14 @@ const fnCache = new Map();
79
181
  const EVAL_IDENT_EXCLUDE = new Set([
80
182
  // Primitive literals / special identifiers
81
183
  'true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'this',
82
- // JS reserved words (shadowing any of these is a SyntaxError)
184
+ // JS reserved words (shadowing any of these is a SyntaxError) — plus
185
+ // `arguments`/`eval`, which are legal identifiers but illegal PARAMETER
186
+ // names in strict mode.
83
187
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
84
188
  'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
85
189
  'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new',
86
190
  'of', 'return', 'super', 'switch', 'throw', 'try', 'typeof', 'var',
87
- 'void', 'while', 'with', 'yield', 'async', 'await',
191
+ 'void', 'while', 'with', 'yield', 'async', 'await', 'arguments', 'eval',
88
192
  // Common runtime globals authors reach for
89
193
  'Math', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date',
90
194
  'JSON', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet',
@@ -98,6 +202,23 @@ const EVAL_IDENT_EXCLUDE = new Set([
98
202
  // a quote (inside a string literal).
99
203
  const FREE_IDENT_REGEX = /(?<![.\w$'"])[a-zA-Z_$][\w$]*/g;
100
204
 
205
+ // Resolve a global identifier through the SCOPE CHAIN, not the globalThis
206
+ // object — top-level let/const/class helpers in classic scripts live in the
207
+ // global declarative record, which `globalThis[p]` can't see. A cached
208
+ // per-identifier probe compiled at global scope reaches both records with
209
+ // the typeof-guard the compiled expressions themselves used pre-3.0.0.
210
+ // `p` is always a regex-validated non-reserved identifier (compileExpression
211
+ // filters keywords via EVAL_IDENT_EXCLUDE), so the probe source is inert.
212
+ const globalProbeCache = new Map();
213
+ const readGlobal = (p) => {
214
+ let probe = globalProbeCache.get(p);
215
+ if (!probe) {
216
+ probe = new Function(`return typeof ${p} === 'undefined' ? undefined : ${p};`);
217
+ globalProbeCache.set(p, probe);
218
+ }
219
+ return probe();
220
+ };
221
+
101
222
  // Evaluate expression in the context of state
102
223
  // Handles @[this.property] for component state and @[property] for global state
103
224
  export const evalInScope = (expr, state, element = null) => {
@@ -114,79 +235,34 @@ export const evalInScope = (expr, state, element = null) => {
114
235
  }
115
236
  }
116
237
 
117
- // Get state keys once. ownKeysOf reads the scoped-state proxy's precomputed
118
- // key list instead of Object.keys() — the latter fires getOwnPropertyDescriptor
119
- // for every key, a per-eval combat hot spot.
120
- const stateKeys = ownKeysOf(state);
121
- const keyCount = stateKeys.length;
122
-
123
- // Cache lookup: expression + key signature compiled function
124
- // All iteration instances share the same state shape, so this hits cache 999/1000 times
125
- const cacheKey = normalized + '\0' + stateKeys.join('\0');
126
- let fn = fnCache.get(cacheKey);
127
- if (!fn) {
128
- const allKeys = new Array(keyCount + 1);
129
- for (let i = 0; i < keyCount; i++) allKeys[i] = stateKeys[i];
130
- allKeys[keyCount] = '$';
131
-
132
- // Rewrite free identifiers that are neither state keys nor known
133
- // globals/reserved words so they resolve to `undefined` instead of
134
- // throwing ReferenceError. `typeof x` is the only operator that can
135
- // probe a name without reading it, so we guard each such reference:
136
- //
137
- // missingProp → (typeof missingProp==='undefined'?void 0:missingProp)
138
- //
139
- // This keeps author intuition working for absent state (`!undef` is
140
- // `true`, `undef?.x` is `undefined`) while leaving real globals
141
- // functions the app exposes on `window` reachable via normal
142
- // identifier lookup. The previous approach (`var undef;` hoist) also
143
- // shadowed those globals to `undefined`, so a template call like
144
- // `getLevelByExperience(exp)` threw TypeError and silently rendered
145
- // "undefined".
146
- const known = new Set(allKeys);
147
- for (const ex of EVAL_IDENT_EXCLUDE) known.add(ex);
148
-
149
- // Arrow function parameters are bound locally — rewriting them breaks
150
- // the arrow's parameter list syntax (e.g. `f =>` must stay a bare ident).
151
- // Collect both `(a, b) =>` and `x =>` forms, strip defaults, and treat
152
- // them as known so the main pass leaves them untouched.
153
- normalized.replace(/\(([^()]*)\)\s*=>/g, (_, paramList) => {
154
- for (const p of paramList.split(',')) {
155
- const name = p.trim().split('=')[0].trim();
156
- if (/^[a-zA-Z_$][\w$]*$/.test(name)) known.add(name);
157
- }
158
- return '';
159
- });
160
- const singleParamRe = /(?:^|[^\w$.])([a-zA-Z_$][\w$]*)\s*=>/g;
161
- let paramMatch;
162
- while ((paramMatch = singleParamRe.exec(normalized)) !== null) {
163
- known.add(paramMatch[1]);
164
- }
165
-
166
- // Match string literals first so hyphens inside them (e.g. 'the-arena')
167
- // don't cause the trailing word to be rewritten as a free identifier.
168
- // Also capture object-literal keys (`{foo: ...}` / `, foo: ...`) as a
169
- // second alternative — they're property names, not references, so
170
- // rewriting them produces invalid JS (computed key without brackets).
171
- const source = normalized.replace(
172
- /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*/g,
173
- (match, strLit, objKey) => {
174
- if (strLit !== undefined) return match;
175
- if (objKey !== undefined) return match;
176
- return known.has(match) ? match : `(typeof ${match}==='undefined'?void 0:${match})`;
177
- },
178
- );
179
- fn = new Function(...allKeys, `'use strict'; return (${source})`);
180
- fnCache.set(cacheKey, fn);
238
+ const { fn, params, cids } = compileExpression(normalized);
239
+
240
+ // Build values matching the compiled parameter order: the expression's
241
+ // free identifiers, the canonicalized component-id strings, and `$` via
242
+ // dollarFor the same state for reads (so old/new diffing works), with
243
+ // helper-method fallback to the live root. An identifier resolves state-
244
+ // first by ENUMERABLE OWN KEY presence ownKeysOf, the exact reach the
245
+ // old key-signature params had: snapshots and scoped states expose their
246
+ // keys there (a key holding undefined still shadows a global), while the
247
+ // root's non-enumerable helpers ($.on, $.unsafe) stay invisible — a state
248
+ // key named `on` must never resolve the hook method. `in` would see
249
+ // those helpers and the prototype chain; ownKeysOf is computed per call,
250
+ // so a live root proxy mutated between evals stays accurate. Everything
251
+ // else resolves through the scope-chain probe (readGlobal) and records
252
+ // an absent-tier read, so the key's later creation re-renders this
253
+ // binding instead of being silently missed.
254
+ const stateObj = state == null ? {} : state;
255
+ const values = new Array(params.length + cids.length + 1);
256
+ const keys = params.length ? ownKeysOf(stateObj) : null;
257
+ let v = 0;
258
+ for (let i = 0; i < params.length; i++) {
259
+ const p = params[i];
260
+ // State-first resolution IS a root-key read record it for the open
261
+ // tracking window (snapshot evals never reach the proxy trap).
262
+ values[v++] = keys.includes(p) ? (recordRead(p), stateObj[p]) : (recordAbsentRead(p), readGlobal(p));
181
263
  }
182
-
183
- // Build values array matching the cached function's parameter order. Bare
184
- // identifiers resolve from the passed (possibly scoped) state; the `$`
185
- // identifier resolves via dollarFor — the same state for reads (so old/new
186
- // diffing works), with helper-method fallback to the live root.
187
- const values = new Array(keyCount + 1);
188
- for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
189
- values[keyCount] = dollarFor(state);
264
+ for (let i = 0; i < cids.length; i++) values[v++] = cids[i];
265
+ values[v] = dollarFor(stateObj);
190
266
 
191
267
  const result = fn(...values);
192
268
 
@@ -209,6 +285,7 @@ export const evalInScope = (expr, state, element = null) => {
209
285
 
210
286
  return result;
211
287
  } catch (e) {
288
+ reportEvalError(expr, element, e);
212
289
  return undefined;
213
290
  }
214
291
  };
@@ -236,7 +313,7 @@ export const resolveCaseInsensitivePath = (state, path) => {
236
313
  // Resolve the root against the SAME scope evalInScope reaches, in its order:
237
314
  // the diff state (including scoped loop aliases — see ownKeysOf below), then
238
315
  // globals. A name-binding inside an iteration reads its props from a global
239
- // stash — `window.__vibeiterprops._p0.statusKey` — so a state-only walk can
316
+ // stash — `window.__vibe.iterProps._p0.statusKey` — so a state-only walk can
240
317
  // never reach it, and the camelCase leaf the parser lowercased stays lost
241
318
  // (the status-chip gray-icon parity bug: resolved in compiled, not runtime).
242
319
  const root = segments[0];
package/spa.js ADDED
@@ -0,0 +1,206 @@
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, and deleting it
9
+ // leaves a working framework. Its one import is the standalone template cache
10
+ // (itself dependency-free), so navigation prefetches ride the same in-flight
11
+ // promise the outlet's mount consumes — one request per fragment. The default
12
+ // onNavigate writes to the runtime global window.$ at call time; pass a
13
+ // custom onNavigate and the module stays a pure router (parse/claim/history)
14
+ // over that cache.
15
+ //
16
+ // Route grammar (shared with the compiler's route table and route scanners):
17
+ // /static/path literal segments
18
+ // /brawlers/:index :param captures one segment
19
+ // /docs/:rest* trailing :name* captures zero or more segments
20
+ // * declared no-match fallback — deep links and popstate
21
+ // only, never claims a click
22
+ // Tables are pre-sorted most-specific-first; first match wins.
23
+
24
+ import { fetchComponentTemplate } from './runtime/component-cache.js';
25
+
26
+ // Captured params arrive percent-encoded from the URL bar; hand authors the
27
+ // decoded value. A malformed escape (user-typed %zz) keeps the raw segment —
28
+ // URLs are external input, not authored code.
29
+ const decode = (segment) => {
30
+ try {
31
+ return decodeURIComponent(segment);
32
+ } catch {
33
+ return segment;
34
+ }
35
+ };
36
+
37
+ // Match one pathname against one route template. Returns the params object
38
+ // (possibly empty) on match, null otherwise. Empty segments are dropped, so
39
+ // trailing slashes resolve to the same route.
40
+ const matchRoute = (path, route) => {
41
+ const routeSegments = route.split('/').filter(Boolean);
42
+ const pathSegments = path.split('/').filter(Boolean);
43
+ const params = {};
44
+ for (let i = 0; i < routeSegments.length; i++) {
45
+ const segment = routeSegments[i];
46
+ if (segment.startsWith(':') && segment.endsWith('*')) {
47
+ params[segment.slice(1, -1)] = pathSegments.slice(i).map(decode).join('/');
48
+ return params;
49
+ }
50
+ if (pathSegments[i] === undefined) return null;
51
+ if (segment.startsWith(':')) params[segment.slice(1)] = decode(pathSegments[i]);
52
+ else if (segment !== pathSegments[i]) return null;
53
+ }
54
+ return pathSegments.length === routeSegments.length ? params : null;
55
+ };
56
+
57
+ // Route slug: segments joined with dashes, params flattened to their bare
58
+ // name — '/' → 'home', '/pve/:id' → 'pve-id', '/docs/:rest*' → 'docs-rest'.
59
+ // Stable across param values, so markup hangs page-scoped attributes and
60
+ // active checks on it: <page @[page.name]>, page.name.startsWith('pve').
61
+ const routeName = (route) =>
62
+ route
63
+ .replace(/^\/+|\/+$/g, '')
64
+ .replace(/:(\w+)\*?/g, '$1')
65
+ .replace(/\//g, '-') || 'home';
66
+
67
+ // Resolve a location (anything with a .pathname, or a bare path string)
68
+ // against a route table → { path, route, params, src, name, title? } | null.
69
+ // Pure: the compiled shell seeds initial $.page with it, unit tests drive it
70
+ // directly. '*' is skipped during matching and applied only when nothing
71
+ // real matched — it is a declared fallback, not a positional route.
72
+ export const resolve = (location, routes) => {
73
+ const path = location.pathname ?? location;
74
+ for (const entry of routes) {
75
+ if (entry.route === '*') continue;
76
+ const params = matchRoute(path, entry.route);
77
+ if (params)
78
+ return {
79
+ path,
80
+ route: entry.route,
81
+ params,
82
+ src: entry.src,
83
+ name: routeName(entry.route),
84
+ title: entry.title,
85
+ };
86
+ }
87
+ const fallback = routes.find((entry) => entry.route === '*');
88
+ // 'not-found', not '*': the slug feeds name bindings (<page @[page.name]>)
89
+ // and setAttribute('*') throws — the fallback must be attribute-safe.
90
+ return fallback
91
+ ? { path, route: '*', params: {}, src: fallback.src, name: 'not-found', title: fallback.title }
92
+ : null;
93
+ };
94
+
95
+ // Programmatic navigation without holding the setupSpa return value: app code
96
+ // imports this and calls it like a link click. With no router active (MPA
97
+ // output, or before setupSpa runs) it falls back to a native load, so the same
98
+ // call site works in both output modes.
99
+ let activeRouter = null;
100
+ export const navigate = (path) =>
101
+ activeRouter ? activeRouter.navigate(path) : location.assign(new URL(path, location.origin));
102
+
103
+ // Wire the router: one document-level click listener + popstate. Claiming
104
+ // rule: same-origin, unmodified, untargeted clicks whose pathname matches a
105
+ // REAL route — '*' never claims, so unrouted paths navigate natively (which
106
+ // is what makes mixed MPA/SPA output work). Returns { navigate, dispose }.
107
+ export const setupSpa = ({ routes, onNavigate }) => {
108
+ // Default Vibe binding: one fresh-object assignment so bindings diff
109
+ // cleanly, reading window.$ at call time (page scripts boot vibe before
110
+ // setupSpa runs). A route-supplied title swaps document.title.
111
+ const apply = onNavigate ?? (({ path, route, params, src, name, title }) => {
112
+ window.$.page = { path, route, params, src, name };
113
+ if (title) document.title = title;
114
+ });
115
+
116
+ const claim = (target) => {
117
+ const resolved = resolve(target, routes);
118
+ return resolved && resolved.route !== '*' ? resolved : null;
119
+ };
120
+
121
+ // Two-phase commit: the URL flips at interaction time, but route state —
122
+ // and with it every binding the outgoing page still renders — flips only
123
+ // once the incoming fragment's template is in hand. The old view never
124
+ // re-renders against the new route, and the prefetch IS the mount's fetch
125
+ // (same cache entry), so a navigation still costs one request. A failed
126
+ // prefetch resolves anyway: the flip proceeds and the mount path owns the
127
+ // error, exactly as before. The token makes rapid navigations last-wins —
128
+ // a superseded prefetch never applies a stale flip.
129
+ let navigationToken = 0;
130
+ const commit = (resolved, { scroll, hash }) => {
131
+ const token = ++navigationToken;
132
+ const prefetch = resolved.src
133
+ ? fetchComponentTemplate(resolved.src).catch(() => {})
134
+ : Promise.resolve();
135
+ prefetch.then(() => {
136
+ if (token !== navigationToken) return;
137
+ apply(resolved);
138
+ // A cross-page hash link scrolls to its anchor once the fragment's
139
+ // mount settles — late ready fires exactly then. Hashless navigations
140
+ // start at the top. getElementById + decode, not querySelector: a URL
141
+ // fragment is an element id, not a CSS selector — digit-leading ids
142
+ // are valid HTML but invalid selectors, and fragments arrive
143
+ // percent-encoded.
144
+ if (hash)
145
+ window.$?.on?.('ready', () =>
146
+ document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView(),
147
+ );
148
+ else if (scroll) scrollTo(0, 0);
149
+ });
150
+ };
151
+
152
+ const push = (url, resolved, hash) => {
153
+ history.pushState({}, '', url);
154
+ commit(resolved, { scroll: true, hash });
155
+ };
156
+
157
+ const onClick = (event) => {
158
+ const anchor = event.target.closest('a[href]');
159
+ if (!anchor || anchor.origin !== location.origin) return;
160
+ if (anchor.target || anchor.hasAttribute('download')) return;
161
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button) return;
162
+ if (event.defaultPrevented) return;
163
+ // Same-page hash links keep the native anchor jump.
164
+ if (anchor.hash && anchor.pathname === location.pathname) return;
165
+ const resolved = claim(anchor);
166
+ if (!resolved) return;
167
+ event.preventDefault();
168
+ // Same-URL click: claimed (no reload), but no duplicate history entry —
169
+ // Back must keep meaning "the previous page". The WHOLE url: a link to
170
+ // the same pathname with a different query string is a real navigation.
171
+ if (anchor.href === location.href) return;
172
+ push(anchor.href, resolved, anchor.hash || null);
173
+ };
174
+
175
+ // History traversal re-resolves WITH the '*' fallback — symmetric with
176
+ // deep-link entry, whose URL may itself be a '*' page. No scroll: the
177
+ // browser restores scroll position on popstate. Same two-phase commit:
178
+ // going back re-renders the old view only when the target is ready.
179
+ const onPopstate = () => {
180
+ const resolved = resolve(location, routes);
181
+ if (resolved) commit(resolved, { scroll: false });
182
+ };
183
+
184
+ // Programmatic navigation, claiming like a link click: real route match →
185
+ // SPA navigation, anything else → native load (the server rewrite serves
186
+ // the shell, whose deep-link resolution may then mount '*').
187
+ const navigate = (path) => {
188
+ const url = new URL(path, location.origin);
189
+ const resolved = claim(url);
190
+ if (resolved) push(url, resolved);
191
+ else location.assign(url);
192
+ };
193
+
194
+ document.addEventListener('click', onClick);
195
+ addEventListener('popstate', onPopstate);
196
+
197
+ const dispose = () => {
198
+ document.removeEventListener('click', onClick);
199
+ removeEventListener('popstate', onPopstate);
200
+ if (activeRouter === api) activeRouter = null;
201
+ };
202
+
203
+ const api = { navigate, dispose };
204
+ activeRouter = api;
205
+ return api;
206
+ };
package/vibe.css CHANGED
@@ -1,12 +1,16 @@
1
- /* Vibe Framework - Hydration Styles
2
- * Elements with [vibe] attribute are hidden until framework removes it after hydration.
3
- * This prevents flash of unprocessed content and disables transitions during init.
4
- */
5
1
  [vibe-fouc],
6
2
  .vibe-fouc {
7
3
  visibility: hidden;
8
4
  }
9
5
 
6
+ /* A route/key remount stages the incoming wrapper as a hidden sibling of the
7
+ * outgoing page (which keeps its place, visible and styled) until the commit
8
+ * swaps them in one paint. display: none keeps the staged content out of
9
+ * layout so the two never occupy the document together. */
10
+ [vibe-staged] {
11
+ display: none;
12
+ }
13
+
10
14
  [vibe-fouc] *,
11
15
  .vibe-fouc * {
12
16
  transition: none !important;