@ape-egg/vibe 2.3.0 → 3.0.1
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 +14 -4
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +10 -15
- package/llms.txt +8 -6
- package/package.json +19 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +312 -99
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +251 -111
- package/runtime/index.js +180 -71
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +69 -5
- 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 +77 -14
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1196
- 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 -2880
- 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 -16
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/spa.rs +0 -477
- 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 -1278
- package/compiler/src/config.rs +0 -279
- package/compiler/src/main.rs +0 -358
- 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/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
|
-
|
|
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
|
|
73
|
-
//
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
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
|
-
|
|
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.
|
|
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
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
// name }, which a reactive component src (<component src="@[page.src]">)
|
|
6
6
|
// turns into a route outlet.
|
|
7
7
|
//
|
|
8
|
-
// SOFT DEPENDENCY: nothing in Vibe's runtime imports this,
|
|
9
|
-
//
|
|
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
|
|
10
12
|
// onNavigate writes to the runtime global window.$ at call time; pass a
|
|
11
|
-
// custom onNavigate and the module
|
|
12
|
-
//
|
|
13
|
+
// custom onNavigate and the module stays a pure router (parse/claim/history)
|
|
14
|
+
// over that cache.
|
|
13
15
|
//
|
|
14
16
|
// Route grammar (shared with the compiler's route table and route scanners):
|
|
15
17
|
// /static/path literal segments
|
|
@@ -19,6 +21,19 @@
|
|
|
19
21
|
// only, never claims a click
|
|
20
22
|
// Tables are pre-sorted most-specific-first; first match wins.
|
|
21
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
|
+
|
|
22
37
|
// Match one pathname against one route template. Returns the params object
|
|
23
38
|
// (possibly empty) on match, null otherwise. Empty segments are dropped, so
|
|
24
39
|
// trailing slashes resolve to the same route.
|
|
@@ -29,11 +44,11 @@ const matchRoute = (path, route) => {
|
|
|
29
44
|
for (let i = 0; i < routeSegments.length; i++) {
|
|
30
45
|
const segment = routeSegments[i];
|
|
31
46
|
if (segment.startsWith(':') && segment.endsWith('*')) {
|
|
32
|
-
params[segment.slice(1, -1)] = pathSegments.slice(i).join('/');
|
|
47
|
+
params[segment.slice(1, -1)] = pathSegments.slice(i).map(decode).join('/');
|
|
33
48
|
return params;
|
|
34
49
|
}
|
|
35
50
|
if (pathSegments[i] === undefined) return null;
|
|
36
|
-
if (segment.startsWith(':')) params[segment.slice(1)] = pathSegments[i];
|
|
51
|
+
if (segment.startsWith(':')) params[segment.slice(1)] = decode(pathSegments[i]);
|
|
37
52
|
else if (segment !== pathSegments[i]) return null;
|
|
38
53
|
}
|
|
39
54
|
return pathSegments.length === routeSegments.length ? params : null;
|
|
@@ -70,11 +85,21 @@ export const resolve = (location, routes) => {
|
|
|
70
85
|
};
|
|
71
86
|
}
|
|
72
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.
|
|
73
90
|
return fallback
|
|
74
|
-
? { path, route: '*', params: {}, src: fallback.src, name: '
|
|
91
|
+
? { path, route: '*', params: {}, src: fallback.src, name: 'not-found', title: fallback.title }
|
|
75
92
|
: null;
|
|
76
93
|
};
|
|
77
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
|
+
|
|
78
103
|
// Wire the router: one document-level click listener + popstate. Claiming
|
|
79
104
|
// rule: same-origin, unmodified, untargeted clicks whose pathname matches a
|
|
80
105
|
// REAL route — '*' never claims, so unrouted paths navigate natively (which
|
|
@@ -93,10 +118,40 @@ export const setupSpa = ({ routes, onNavigate }) => {
|
|
|
93
118
|
return resolved && resolved.route !== '*' ? resolved : null;
|
|
94
119
|
};
|
|
95
120
|
|
|
96
|
-
|
|
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) => {
|
|
97
153
|
history.pushState({}, '', url);
|
|
98
|
-
|
|
99
|
-
scrollTo(0, 0);
|
|
154
|
+
commit(resolved, { scroll: true, hash });
|
|
100
155
|
};
|
|
101
156
|
|
|
102
157
|
const onClick = (event) => {
|
|
@@ -110,15 +165,20 @@ export const setupSpa = ({ routes, onNavigate }) => {
|
|
|
110
165
|
const resolved = claim(anchor);
|
|
111
166
|
if (!resolved) return;
|
|
112
167
|
event.preventDefault();
|
|
113
|
-
|
|
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);
|
|
114
173
|
};
|
|
115
174
|
|
|
116
175
|
// History traversal re-resolves WITH the '*' fallback — symmetric with
|
|
117
176
|
// deep-link entry, whose URL may itself be a '*' page. No scroll: the
|
|
118
|
-
// browser restores scroll position on popstate.
|
|
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.
|
|
119
179
|
const onPopstate = () => {
|
|
120
180
|
const resolved = resolve(location, routes);
|
|
121
|
-
if (resolved)
|
|
181
|
+
if (resolved) commit(resolved, { scroll: false });
|
|
122
182
|
};
|
|
123
183
|
|
|
124
184
|
// Programmatic navigation, claiming like a link click: real route match →
|
|
@@ -137,7 +197,10 @@ export const setupSpa = ({ routes, onNavigate }) => {
|
|
|
137
197
|
const dispose = () => {
|
|
138
198
|
document.removeEventListener('click', onClick);
|
|
139
199
|
removeEventListener('popstate', onPopstate);
|
|
200
|
+
if (activeRouter === api) activeRouter = null;
|
|
140
201
|
};
|
|
141
202
|
|
|
142
|
-
|
|
203
|
+
const api = { navigate, dispose };
|
|
204
|
+
activeRouter = api;
|
|
205
|
+
return api;
|
|
143
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;
|