@vmz/core 0.0.4 → 0.1.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/dist/client-nav.d.ts +20 -2
- package/dist/client-nav.js +560 -50
- package/dist/dom-core.d.ts +337 -0
- package/dist/dom-core.js +4565 -0
- package/dist/dom-ssr.d.ts +78 -0
- package/dist/dom-ssr.js +970 -0
- package/dist/dom.client.d.ts +4 -0
- package/dist/dom.client.js +5 -0
- package/dist/dom.d.ts +3 -219
- package/dist/dom.js +3 -4377
- package/dist/serve-host.d.ts +13 -0
- package/dist/serve-host.mjs +184 -16
- package/dist/server.d.ts +8 -0
- package/dist/server.js +218 -10
- package/dist/vmz-dom.d.ts +5 -0
- package/dist/vmz-dom.js +6 -0
- package/dist/vmz-runtime.d.ts +5 -0
- package/dist/vmz-runtime.js +6 -0
- package/package.json +8 -1
package/dist/client-nav.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser client navigation — same-app `<Link>` SPA takeover.
|
|
3
3
|
* Zero-JS still works via real `<a href>`; with JS, intercept same-origin
|
|
4
|
-
* `a[data-vmz-route]` clicks, pushState, fetch SSR HTML, swap
|
|
4
|
+
* `a[data-vmz-route]` clicks, pushState, fetch SSR HTML, swap page (retain layout
|
|
5
|
+
* when `data-vmz-layout` is unchanged) or full `#app`, then hydrate.
|
|
5
6
|
*/
|
|
6
7
|
// @ts-nocheck
|
|
7
8
|
/**
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
* location?: Location,
|
|
13
14
|
* hydrate?: (Ctor: any, root: Element, props: object) => Promise<unknown>,
|
|
14
15
|
* hydrateRoute?: (Page: any, root: Element, props: object, layouts?: any[]) => Promise<unknown>,
|
|
16
|
+
* hydrateRoutePage?: (Page: any, root: Element, props: object) => Promise<unknown>,
|
|
15
17
|
* destroy?: (inst: object) => void,
|
|
16
18
|
* importPage?: (chunkId: string) => Promise<any>,
|
|
17
19
|
* }} [opts]
|
|
@@ -21,7 +23,9 @@ export function installClientNavigation(opts = {}) {
|
|
|
21
23
|
const win = typeof window !== 'undefined' ? window : null;
|
|
22
24
|
const hist = opts.history || win?.history;
|
|
23
25
|
const loc = opts.location || win?.location;
|
|
24
|
-
const
|
|
26
|
+
const fetchImplDefault = opts.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
|
27
|
+
/** @type {typeof fetch | null} */
|
|
28
|
+
let fetchImpl = fetchImplDefault;
|
|
25
29
|
if (!doc || !hist || !loc || !fetchImpl) {
|
|
26
30
|
return { ok: false, reason: 'missing document/history/fetch' };
|
|
27
31
|
}
|
|
@@ -30,15 +34,156 @@ export function installClientNavigation(opts = {}) {
|
|
|
30
34
|
}
|
|
31
35
|
if (win)
|
|
32
36
|
win.__vmzClientNavInstalled = true;
|
|
37
|
+
// Route Transition Plan owns scroll; disable browser's automatic restoration.
|
|
38
|
+
try {
|
|
39
|
+
if (hist && 'scrollRestoration' in hist)
|
|
40
|
+
hist.scrollRestoration = 'manual';
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
33
45
|
/** @type {AbortController | null} */
|
|
34
46
|
let inflight = null;
|
|
35
47
|
let navigating = false;
|
|
36
|
-
|
|
48
|
+
/** @type {Map<string, { x: number, y: number }>} */
|
|
49
|
+
const scrollPositions = new Map();
|
|
50
|
+
function destroyInst(inst) {
|
|
51
|
+
if (!inst)
|
|
52
|
+
return;
|
|
53
|
+
if (typeof opts.destroy === 'function')
|
|
54
|
+
opts.destroy(inst);
|
|
55
|
+
else if (win?.vmzDestroy)
|
|
56
|
+
win.vmzDestroy(inst);
|
|
57
|
+
}
|
|
58
|
+
async function loadDomFallback() {
|
|
59
|
+
return import(/* @vite-ignore */ '/vmz-dom.js');
|
|
60
|
+
}
|
|
61
|
+
function navKey(pathname, search) {
|
|
62
|
+
return `${pathname || '/'}${search || ''}`;
|
|
63
|
+
}
|
|
64
|
+
function saveScroll() {
|
|
65
|
+
if (!win)
|
|
66
|
+
return;
|
|
67
|
+
scrollPositions.set(navKey(loc.pathname, loc.search), {
|
|
68
|
+
x: win.scrollX || 0,
|
|
69
|
+
y: win.scrollY || 0,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Route Transition Plan: restore scroll on popstate; forward nav → hash or top.
|
|
74
|
+
* Re-applies across frames until scrollHeight can hold the saved Y (hydrate settle).
|
|
75
|
+
* @param {URL} target
|
|
76
|
+
* @param {boolean} fromPop
|
|
77
|
+
*/
|
|
78
|
+
async function restoreScroll(target, fromPop) {
|
|
79
|
+
if (!win)
|
|
80
|
+
return { mode: 'none', x: 0, y: 0 };
|
|
81
|
+
const frame = () => new Promise((resolve) => {
|
|
82
|
+
if (typeof win.requestAnimationFrame === 'function')
|
|
83
|
+
win.requestAnimationFrame(resolve);
|
|
84
|
+
else
|
|
85
|
+
setTimeout(resolve, 0);
|
|
86
|
+
});
|
|
87
|
+
if (fromPop) {
|
|
88
|
+
const saved = scrollPositions.get(navKey(target.pathname, target.search));
|
|
89
|
+
if (saved) {
|
|
90
|
+
const apply = () => {
|
|
91
|
+
try {
|
|
92
|
+
win.scrollTo(saved.x, saved.y);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* ignore */
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// Do not claim restored until window.scrollY actually tracks (or we exhaust retries).
|
|
99
|
+
for (let i = 0; i < 12; i++) {
|
|
100
|
+
apply();
|
|
101
|
+
const y = win.scrollY || 0;
|
|
102
|
+
if (Math.abs(y - saved.y) <= 2)
|
|
103
|
+
break;
|
|
104
|
+
const docEl = doc.documentElement || doc.body;
|
|
105
|
+
const maxY = Math.max(0, (docEl?.scrollHeight || 0) - (win.innerHeight || 0));
|
|
106
|
+
// Document still short — wait for hydrate/layout to grow scrollHeight.
|
|
107
|
+
if (maxY + 2 < saved.y) {
|
|
108
|
+
await frame();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Tall enough but not yet at target (rare paint lag).
|
|
112
|
+
await frame();
|
|
113
|
+
}
|
|
114
|
+
apply();
|
|
115
|
+
return { mode: 'restored', x: saved.x, y: win.scrollY || saved.y };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (target.hash) {
|
|
119
|
+
const id = decodeURIComponent(target.hash.slice(1));
|
|
120
|
+
const el = id ? doc.getElementById(id) : null;
|
|
121
|
+
if (el && typeof el.scrollIntoView === 'function') {
|
|
122
|
+
el.scrollIntoView();
|
|
123
|
+
return { mode: 'hash', x: win.scrollX || 0, y: win.scrollY || 0 };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
win.scrollTo(0, 0);
|
|
127
|
+
return { mode: 'top', x: 0, y: 0 };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Focus the primary page landmark after SPA swap (not a scattered runtime hook).
|
|
131
|
+
* @param {Element | null} root
|
|
132
|
+
* @param {URL} target
|
|
133
|
+
*/
|
|
134
|
+
function restoreFocus(root, target) {
|
|
135
|
+
if (!root || !doc)
|
|
136
|
+
return null;
|
|
137
|
+
let el = null;
|
|
138
|
+
if (target.hash) {
|
|
139
|
+
const id = decodeURIComponent(target.hash.slice(1));
|
|
140
|
+
el = id ? doc.getElementById(id) : null;
|
|
141
|
+
}
|
|
142
|
+
if (!el)
|
|
143
|
+
el = root.querySelector('[data-vmz-focus]');
|
|
144
|
+
if (!el)
|
|
145
|
+
el = root.querySelector('main, h1, [role="main"]');
|
|
146
|
+
if (!el)
|
|
147
|
+
el = root;
|
|
148
|
+
if (el === doc.body)
|
|
149
|
+
return null;
|
|
150
|
+
const focusable = /** @type {HTMLElement} */ (el);
|
|
151
|
+
if (!focusable.hasAttribute('tabindex') && focusable.tabIndex < 0) {
|
|
152
|
+
focusable.setAttribute('tabindex', '-1');
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
focusable.focus({ preventScroll: true });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* ignore — never focus() without preventScroll (would steal restored scrollY) */
|
|
159
|
+
}
|
|
160
|
+
return focusable.getAttribute('data-vmz-focus') || focusable.tagName?.toLowerCase() || null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Apply locale realization attributes from SSR `#app` onto `<html>`.
|
|
164
|
+
* @param {Element | null} root
|
|
165
|
+
*/
|
|
166
|
+
function applyLocaleRealization(root) {
|
|
167
|
+
if (!root || !doc?.documentElement)
|
|
168
|
+
return null;
|
|
169
|
+
const locale = root.getAttribute('data-vmz-locale');
|
|
170
|
+
const dir = root.getAttribute('data-vmz-dir');
|
|
171
|
+
if (locale) {
|
|
172
|
+
doc.documentElement.setAttribute('data-locale', locale);
|
|
173
|
+
doc.documentElement.lang = locale;
|
|
174
|
+
}
|
|
175
|
+
if (dir)
|
|
176
|
+
doc.documentElement.dir = dir;
|
|
177
|
+
return locale;
|
|
178
|
+
}
|
|
179
|
+
async function transitionTo(url, { replace = false, fromPop = false, softFail = false } = {}) {
|
|
37
180
|
const target = new URL(url, loc.href);
|
|
38
181
|
if (target.origin !== loc.origin) {
|
|
39
182
|
loc.assign(target.href);
|
|
40
183
|
return { ok: false, reason: 'cross-origin' };
|
|
41
184
|
}
|
|
185
|
+
if (!fromPop)
|
|
186
|
+
saveScroll();
|
|
42
187
|
if (inflight)
|
|
43
188
|
inflight.abort();
|
|
44
189
|
inflight = new AbortController();
|
|
@@ -51,85 +196,150 @@ export function installClientNavigation(opts = {}) {
|
|
|
51
196
|
signal,
|
|
52
197
|
});
|
|
53
198
|
if (!res.ok) {
|
|
54
|
-
//
|
|
55
|
-
if (!fromPop)
|
|
199
|
+
// LocaleTransition uses softFail — keep current surface (no full assign).
|
|
200
|
+
if (!fromPop && !softFail)
|
|
56
201
|
loc.assign(target.href);
|
|
57
202
|
return { ok: false, reason: `http ${res.status}` };
|
|
58
203
|
}
|
|
59
204
|
const html = await res.text();
|
|
60
205
|
const nextApp = extractAppHtml(html, doc);
|
|
61
206
|
if (!nextApp) {
|
|
62
|
-
if (!fromPop)
|
|
207
|
+
if (!fromPop && !softFail)
|
|
63
208
|
loc.assign(target.href);
|
|
64
209
|
return { ok: false, reason: 'missing #app in response' };
|
|
65
210
|
}
|
|
66
211
|
const root = doc.getElementById('app');
|
|
67
212
|
if (!root) {
|
|
68
|
-
if (!fromPop)
|
|
213
|
+
if (!fromPop && !softFail)
|
|
69
214
|
loc.assign(target.href);
|
|
70
215
|
return { ok: false, reason: 'missing #app' };
|
|
71
216
|
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
else if (prev && win?.vmzDestroy) {
|
|
78
|
-
win.vmzDestroy(prev);
|
|
79
|
-
}
|
|
80
|
-
root.__vmzInst = null;
|
|
81
|
-
root.outerHTML = nextApp.outerHTML;
|
|
82
|
-
const fresh = doc.getElementById('app');
|
|
83
|
-
if (!fresh)
|
|
84
|
-
return { ok: false, reason: 'swap lost #app' };
|
|
85
|
-
if (!fromPop) {
|
|
86
|
-
if (replace)
|
|
87
|
-
hist.replaceState({ vmzClientNav: true }, '', target.href);
|
|
88
|
-
else
|
|
89
|
-
hist.pushState({ vmzClientNav: true }, '', target.href);
|
|
90
|
-
}
|
|
91
|
-
const chunkId = fresh.getAttribute('data-vmz-page') || '';
|
|
217
|
+
const prevLayout = parseLayoutChain(root.getAttribute('data-vmz-layout'));
|
|
218
|
+
const nextLayout = parseLayoutChain(nextApp.getAttribute('data-vmz-layout'));
|
|
219
|
+
const retainLayouts = canRetainLayouts(root, prevLayout, nextLayout);
|
|
220
|
+
const chunkId = nextApp.getAttribute('data-vmz-page') || '';
|
|
92
221
|
let props = {};
|
|
93
222
|
try {
|
|
94
|
-
const raw =
|
|
223
|
+
const raw = nextApp.getAttribute('data-vmz-props');
|
|
95
224
|
if (raw)
|
|
96
225
|
props = JSON.parse(raw);
|
|
97
226
|
}
|
|
98
227
|
catch {
|
|
99
228
|
/* ignore */
|
|
100
229
|
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
230
|
+
if (!fromPop) {
|
|
231
|
+
if (replace)
|
|
232
|
+
hist.replaceState({ vmzClientNav: true }, '', target.href);
|
|
233
|
+
else
|
|
234
|
+
hist.pushState({ vmzClientNav: true }, '', target.href);
|
|
235
|
+
}
|
|
236
|
+
const importChunk = opts.importPage || (async (id) => (await import(/* @vite-ignore */ `/${id}.client.js`)).default);
|
|
237
|
+
/** @type {Element | null} */
|
|
238
|
+
let liveRoot = root;
|
|
239
|
+
let retainedLayout = false;
|
|
240
|
+
if (retainLayouts) {
|
|
241
|
+
applyAppAttrs(root, nextApp);
|
|
242
|
+
if (chunkId) {
|
|
243
|
+
const Page = await importChunk(chunkId);
|
|
244
|
+
let hydrateRoutePage = opts.hydrateRoutePage;
|
|
245
|
+
if (typeof hydrateRoutePage !== 'function') {
|
|
246
|
+
const dom = await loadDomFallback();
|
|
247
|
+
hydrateRoutePage = dom.hydrateRoutePage;
|
|
248
|
+
}
|
|
249
|
+
if (typeof hydrateRoutePage === 'function') {
|
|
250
|
+
await hydrateRoutePage(Page, root, props);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
let hydrate = opts.hydrate;
|
|
254
|
+
if (typeof hydrate !== 'function') {
|
|
255
|
+
const dom = await loadDomFallback();
|
|
256
|
+
hydrate = dom.hydrate;
|
|
257
|
+
}
|
|
258
|
+
const pageHost = root.__vmzPageHost || root;
|
|
259
|
+
if (pageHost.__vmzInst)
|
|
260
|
+
destroyInst(pageHost.__vmzInst);
|
|
261
|
+
await hydrate(Page, pageHost, props);
|
|
262
|
+
root.__vmzPageHost = pageHost;
|
|
263
|
+
if (!root.__vmzLayoutInsts?.length)
|
|
264
|
+
root.__vmzInst = pageHost.__vmzInst;
|
|
265
|
+
}
|
|
117
266
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
267
|
+
retainedLayout = true;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Dispose previous Direct instance if present (full #app swap).
|
|
271
|
+
const prev = root.__vmzInst;
|
|
272
|
+
destroyInst(prev);
|
|
273
|
+
root.__vmzInst = null;
|
|
274
|
+
root.__vmzPageHost = null;
|
|
275
|
+
root.__vmzLayoutInsts = null;
|
|
276
|
+
root.outerHTML = nextApp.outerHTML;
|
|
277
|
+
const fresh = doc.getElementById('app');
|
|
278
|
+
if (!fresh)
|
|
279
|
+
return { ok: false, reason: 'swap lost #app' };
|
|
280
|
+
liveRoot = fresh;
|
|
281
|
+
if (chunkId) {
|
|
282
|
+
const Page = await importChunk(chunkId);
|
|
283
|
+
/** @type {any[]} */
|
|
284
|
+
const layoutCtors = [];
|
|
285
|
+
for (const id of nextLayout) {
|
|
286
|
+
layoutCtors.push(await importChunk(id));
|
|
287
|
+
}
|
|
288
|
+
let hydrateRoute = opts.hydrateRoute;
|
|
289
|
+
if (typeof hydrateRoute !== 'function') {
|
|
290
|
+
const dom = await loadDomFallback();
|
|
291
|
+
hydrateRoute = dom.hydrateRoute;
|
|
292
|
+
}
|
|
293
|
+
if (typeof hydrateRoute === 'function') {
|
|
294
|
+
await hydrateRoute(Page, fresh, props, layoutCtors);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
let hydrate = opts.hydrate;
|
|
298
|
+
if (typeof hydrate !== 'function') {
|
|
299
|
+
const dom = await loadDomFallback();
|
|
300
|
+
hydrate = dom.hydrate;
|
|
301
|
+
}
|
|
302
|
+
await hydrate(Page, fresh, props);
|
|
303
|
+
}
|
|
121
304
|
}
|
|
122
305
|
}
|
|
306
|
+
const localeId = applyLocaleRealization(liveRoot);
|
|
307
|
+
const focusTarget = restoreFocus(liveRoot, target);
|
|
123
308
|
if (win) {
|
|
124
309
|
win.__vmzClientNavCount = (win.__vmzClientNavCount || 0) + 1;
|
|
125
310
|
win.__vmzLastClientNav = {
|
|
126
311
|
href: target.pathname + target.search,
|
|
127
|
-
routeId: nextApp.getAttribute?.('data-vmz-route') || null,
|
|
312
|
+
routeId: liveRoot?.getAttribute?.('data-vmz-route') || nextApp.getAttribute?.('data-vmz-route') || null,
|
|
128
313
|
chunkId,
|
|
129
314
|
bootId: win.__vmzBootId,
|
|
315
|
+
retainedLayout,
|
|
316
|
+
focusTarget,
|
|
317
|
+
localeId,
|
|
318
|
+
// Pending until restoreScroll finishes — never lie as "restored" early (gate race).
|
|
319
|
+
scrollMode: 'pending',
|
|
320
|
+
scrollY: null,
|
|
130
321
|
};
|
|
131
322
|
}
|
|
132
|
-
|
|
323
|
+
// Wait for hydrate/layout paint before applying scroll — otherwise scrollTo is clamped/reset.
|
|
324
|
+
if (win && typeof win.requestAnimationFrame === 'function') {
|
|
325
|
+
await new Promise((resolve) => {
|
|
326
|
+
win.requestAnimationFrame(() => win.requestAnimationFrame(resolve));
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const scroll = await restoreScroll(target, fromPop);
|
|
330
|
+
if (win && win.__vmzLastClientNav) {
|
|
331
|
+
win.__vmzLastClientNav.scrollMode = scroll.mode;
|
|
332
|
+
win.__vmzLastClientNav.scrollY = scroll.y;
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
href: target.pathname + target.search,
|
|
337
|
+
chunkId,
|
|
338
|
+
retainedLayout,
|
|
339
|
+
scrollMode: scroll.mode,
|
|
340
|
+
focusTarget,
|
|
341
|
+
localeId,
|
|
342
|
+
};
|
|
133
343
|
}
|
|
134
344
|
finally {
|
|
135
345
|
navigating = false;
|
|
@@ -157,24 +367,324 @@ export function installClientNavigation(opts = {}) {
|
|
|
157
367
|
if (a.hasAttribute('download') || a.getAttribute('target') === '_blank')
|
|
158
368
|
return;
|
|
159
369
|
ev.preventDefault();
|
|
160
|
-
|
|
370
|
+
// Retain current LocaleId on same-app Link (realization) — never trust a stale unprefixed href.
|
|
371
|
+
const realized = localizeClickHref(u.pathname + u.search + u.hash);
|
|
372
|
+
void transitionTo(realized, {
|
|
161
373
|
replace: a.getAttribute('data-vmz-replace') === 'true',
|
|
162
374
|
});
|
|
163
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* @param {string} href
|
|
378
|
+
*/
|
|
379
|
+
function localizeClickHref(href) {
|
|
380
|
+
if (!doc?.documentElement)
|
|
381
|
+
return href;
|
|
382
|
+
const locale = doc.documentElement.getAttribute('data-locale');
|
|
383
|
+
const raw = doc.documentElement.getAttribute('data-vmz-locale-routing');
|
|
384
|
+
if (!locale || !raw)
|
|
385
|
+
return href;
|
|
386
|
+
let routing;
|
|
387
|
+
try {
|
|
388
|
+
routing = JSON.parse(raw);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return href;
|
|
392
|
+
}
|
|
393
|
+
const supported = Array.isArray(routing.locales) ? routing.locales : [];
|
|
394
|
+
const defaultLocale = routing.defaultLocale;
|
|
395
|
+
let pathname = href;
|
|
396
|
+
let search = '';
|
|
397
|
+
let hash = '';
|
|
398
|
+
const hashIdx = pathname.indexOf('#');
|
|
399
|
+
if (hashIdx >= 0) {
|
|
400
|
+
hash = pathname.slice(hashIdx);
|
|
401
|
+
pathname = pathname.slice(0, hashIdx);
|
|
402
|
+
}
|
|
403
|
+
const qIdx = pathname.indexOf('?');
|
|
404
|
+
if (qIdx >= 0) {
|
|
405
|
+
search = pathname.slice(qIdx);
|
|
406
|
+
pathname = pathname.slice(0, qIdx);
|
|
407
|
+
}
|
|
408
|
+
if (!pathname)
|
|
409
|
+
pathname = '/';
|
|
410
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
411
|
+
// Explicit locale prefix in href = intentional locale (switch/deep-link) — do not rewrite.
|
|
412
|
+
if (parts.length && supported.includes(parts[0])) {
|
|
413
|
+
return `${pathname}${search}${hash}`;
|
|
414
|
+
}
|
|
415
|
+
// Unprefixed same-app Link → realize with current LocaleId.
|
|
416
|
+
let rest = pathname;
|
|
417
|
+
if (rest.length > 1 && rest.endsWith('/'))
|
|
418
|
+
rest = rest.slice(0, -1);
|
|
419
|
+
if (!rest.startsWith('/'))
|
|
420
|
+
rest = `/${rest}`;
|
|
421
|
+
const strategy = routing.strategy || 'prefix';
|
|
422
|
+
const defaultPrefix = routing.defaultPrefix || 'include';
|
|
423
|
+
if (strategy === 'none' || strategy === 'domain')
|
|
424
|
+
return `${rest}${search}${hash}`;
|
|
425
|
+
if (defaultPrefix === 'omit' && locale === defaultLocale)
|
|
426
|
+
return `${rest}${search}${hash}`;
|
|
427
|
+
const pathOut = rest === '/' ? `/${locale}` : `/${locale}${rest}`;
|
|
428
|
+
return `${pathOut}${search}${hash}`;
|
|
429
|
+
}
|
|
164
430
|
function onPopState() {
|
|
165
431
|
void transitionTo(loc.pathname + loc.search + loc.hash, { fromPop: true });
|
|
166
432
|
}
|
|
433
|
+
/** @type {number} */
|
|
434
|
+
let localeTransitionGeneration = 0;
|
|
435
|
+
/**
|
|
436
|
+
* Atomic LocaleTransition (browser host slice):
|
|
437
|
+
* validate → realize path → navigate/fetch → commit locale attrs from SSR HTML.
|
|
438
|
+
* Failure keeps the previous locale surface (no half-page commit).
|
|
439
|
+
* @param {string} toLocale
|
|
440
|
+
* @param {{ replace?: boolean }} [opts]
|
|
441
|
+
*/
|
|
442
|
+
async function transitionLocale(toLocale, opts = {}) {
|
|
443
|
+
const fromLocale = doc.documentElement?.getAttribute('data-locale') || null;
|
|
444
|
+
const routing = readLocaleRouting();
|
|
445
|
+
if (!routing) {
|
|
446
|
+
const out = {
|
|
447
|
+
status: 'rejected',
|
|
448
|
+
fromLocale,
|
|
449
|
+
toLocale,
|
|
450
|
+
reason: 'missing_routing',
|
|
451
|
+
};
|
|
452
|
+
if (win)
|
|
453
|
+
win.__vmzLastLocaleTransition = out;
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
const supported = Array.isArray(routing.locales) ? routing.locales : [];
|
|
457
|
+
if (!supported.includes(toLocale)) {
|
|
458
|
+
const out = {
|
|
459
|
+
status: 'rejected',
|
|
460
|
+
fromLocale,
|
|
461
|
+
toLocale,
|
|
462
|
+
reason: 'unsupported',
|
|
463
|
+
};
|
|
464
|
+
if (win)
|
|
465
|
+
win.__vmzLastLocaleTransition = out;
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
if (toLocale === fromLocale) {
|
|
469
|
+
const out = {
|
|
470
|
+
status: 'committed',
|
|
471
|
+
fromLocale,
|
|
472
|
+
toLocale,
|
|
473
|
+
reason: 'noop',
|
|
474
|
+
href: loc.pathname + loc.search,
|
|
475
|
+
};
|
|
476
|
+
if (win)
|
|
477
|
+
win.__vmzLastLocaleTransition = out;
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
const gen = ++localeTransitionGeneration;
|
|
481
|
+
const targetHref = realizePathForLocale(loc.pathname + loc.search + loc.hash, toLocale, routing);
|
|
482
|
+
const result = await transitionTo(targetHref, { replace: opts.replace !== false, softFail: true });
|
|
483
|
+
if (gen !== localeTransitionGeneration) {
|
|
484
|
+
const out = {
|
|
485
|
+
status: 'cancelled',
|
|
486
|
+
fromLocale,
|
|
487
|
+
toLocale,
|
|
488
|
+
reason: 'stale_generation',
|
|
489
|
+
href: targetHref,
|
|
490
|
+
generation: gen,
|
|
491
|
+
};
|
|
492
|
+
if (win)
|
|
493
|
+
win.__vmzLastLocaleTransition = out;
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
if (!result?.ok) {
|
|
497
|
+
// transitionTo does not mutate html locale attrs before success — surface stays fromLocale.
|
|
498
|
+
const still = doc.documentElement?.getAttribute('data-locale');
|
|
499
|
+
const out = {
|
|
500
|
+
status: 'rolled_back',
|
|
501
|
+
fromLocale,
|
|
502
|
+
toLocale,
|
|
503
|
+
reason: result?.reason || 'nav_failed',
|
|
504
|
+
href: targetHref,
|
|
505
|
+
retainedLocale: still,
|
|
506
|
+
generation: gen,
|
|
507
|
+
};
|
|
508
|
+
if (win)
|
|
509
|
+
win.__vmzLastLocaleTransition = out;
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
const committed = doc.documentElement?.getAttribute('data-locale');
|
|
513
|
+
if (committed !== toLocale) {
|
|
514
|
+
const out = {
|
|
515
|
+
status: 'failed',
|
|
516
|
+
fromLocale,
|
|
517
|
+
toLocale,
|
|
518
|
+
reason: 'partial',
|
|
519
|
+
href: targetHref,
|
|
520
|
+
committedLocale: committed,
|
|
521
|
+
generation: gen,
|
|
522
|
+
};
|
|
523
|
+
if (win)
|
|
524
|
+
win.__vmzLastLocaleTransition = out;
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
const out = {
|
|
528
|
+
status: 'committed',
|
|
529
|
+
fromLocale,
|
|
530
|
+
toLocale,
|
|
531
|
+
reason: 'ok',
|
|
532
|
+
href: targetHref,
|
|
533
|
+
generation: gen,
|
|
534
|
+
};
|
|
535
|
+
if (win)
|
|
536
|
+
win.__vmzLastLocaleTransition = out;
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* @returns {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string, locales?: string[] } | null}
|
|
541
|
+
*/
|
|
542
|
+
function readLocaleRouting() {
|
|
543
|
+
const raw = doc.documentElement?.getAttribute('data-vmz-locale-routing');
|
|
544
|
+
if (!raw)
|
|
545
|
+
return null;
|
|
546
|
+
try {
|
|
547
|
+
return JSON.parse(raw);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Re-realize current URL under target LocaleId (stable path stays; LocaleId is projection).
|
|
555
|
+
* @param {string} href
|
|
556
|
+
* @param {string} localeId
|
|
557
|
+
* @param {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string, locales?: string[] }} routing
|
|
558
|
+
*/
|
|
559
|
+
function realizePathForLocale(href, localeId, routing) {
|
|
560
|
+
let pathname = href;
|
|
561
|
+
let search = '';
|
|
562
|
+
let hash = '';
|
|
563
|
+
const hashIdx = pathname.indexOf('#');
|
|
564
|
+
if (hashIdx >= 0) {
|
|
565
|
+
hash = pathname.slice(hashIdx);
|
|
566
|
+
pathname = pathname.slice(0, hashIdx);
|
|
567
|
+
}
|
|
568
|
+
const qIdx = pathname.indexOf('?');
|
|
569
|
+
if (qIdx >= 0) {
|
|
570
|
+
search = pathname.slice(qIdx);
|
|
571
|
+
pathname = pathname.slice(0, qIdx);
|
|
572
|
+
}
|
|
573
|
+
if (!pathname)
|
|
574
|
+
pathname = '/';
|
|
575
|
+
const supported = Array.isArray(routing.locales) ? routing.locales : [];
|
|
576
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
577
|
+
let rest = pathname;
|
|
578
|
+
if (parts.length && supported.includes(parts[0])) {
|
|
579
|
+
const r = parts.slice(1);
|
|
580
|
+
rest = r.length ? `/${r.join('/')}` : '/';
|
|
581
|
+
}
|
|
582
|
+
if (rest.length > 1 && rest.endsWith('/'))
|
|
583
|
+
rest = rest.slice(0, -1);
|
|
584
|
+
if (!rest.startsWith('/'))
|
|
585
|
+
rest = `/${rest}`;
|
|
586
|
+
const strategy = routing.strategy || 'prefix';
|
|
587
|
+
const defaultPrefix = routing.defaultPrefix || 'include';
|
|
588
|
+
const defaultLocale = routing.defaultLocale;
|
|
589
|
+
if (strategy === 'none' || strategy === 'domain')
|
|
590
|
+
return `${rest}${search}${hash}`;
|
|
591
|
+
if (defaultPrefix === 'omit' && localeId === defaultLocale)
|
|
592
|
+
return `${rest}${search}${hash}`;
|
|
593
|
+
const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
|
|
594
|
+
return `${pathOut}${search}${hash}`;
|
|
595
|
+
}
|
|
596
|
+
if (win) {
|
|
597
|
+
win.__vmzTransitionLocale = transitionLocale;
|
|
598
|
+
win.__vmzClientNavSetFetch = (fn) => {
|
|
599
|
+
fetchImpl = typeof fn === 'function' ? fn : fetchImplDefault;
|
|
600
|
+
};
|
|
601
|
+
}
|
|
167
602
|
doc.addEventListener('click', onClick);
|
|
168
603
|
win?.addEventListener?.('popstate', onPopState);
|
|
169
604
|
return {
|
|
170
605
|
ok: true,
|
|
171
606
|
transitionTo,
|
|
607
|
+
transitionLocale,
|
|
172
608
|
dispose() {
|
|
173
609
|
doc.removeEventListener('click', onClick);
|
|
174
610
|
win?.removeEventListener?.('popstate', onPopState);
|
|
611
|
+
if (win && win.__vmzTransitionLocale === transitionLocale) {
|
|
612
|
+
try {
|
|
613
|
+
delete win.__vmzTransitionLocale;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
win.__vmzTransitionLocale = undefined;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (win) {
|
|
620
|
+
try {
|
|
621
|
+
delete win.__vmzClientNavSetFetch;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
win.__vmzClientNavSetFetch = undefined;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
175
627
|
},
|
|
176
628
|
};
|
|
177
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* @param {string | null} raw
|
|
632
|
+
* @returns {string[]}
|
|
633
|
+
*/
|
|
634
|
+
function parseLayoutChain(raw) {
|
|
635
|
+
return String(raw || '')
|
|
636
|
+
.split(',')
|
|
637
|
+
.map((s) => s.trim())
|
|
638
|
+
.filter(Boolean);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* @param {string[]} a
|
|
642
|
+
* @param {string[]} b
|
|
643
|
+
*/
|
|
644
|
+
function layoutChainsEqual(a, b) {
|
|
645
|
+
if (a.length !== b.length)
|
|
646
|
+
return false;
|
|
647
|
+
for (let i = 0; i < a.length; i++) {
|
|
648
|
+
if (a[i] !== b[i])
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* @param {Element} root
|
|
655
|
+
* @param {string[]} prevLayout
|
|
656
|
+
* @param {string[]} nextLayout
|
|
657
|
+
*/
|
|
658
|
+
function canRetainLayouts(root, prevLayout, nextLayout) {
|
|
659
|
+
if (!layoutChainsEqual(prevLayout, nextLayout))
|
|
660
|
+
return false;
|
|
661
|
+
if (!root.__vmzPageHost)
|
|
662
|
+
return false;
|
|
663
|
+
if (nextLayout.length === 0)
|
|
664
|
+
return true;
|
|
665
|
+
const insts = root.__vmzLayoutInsts;
|
|
666
|
+
return Array.isArray(insts) && insts.length === nextLayout.length;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* @param {Element} root
|
|
670
|
+
* @param {Element} nextApp
|
|
671
|
+
*/
|
|
672
|
+
function applyAppAttrs(root, nextApp) {
|
|
673
|
+
for (const name of [
|
|
674
|
+
'data-vmz-page',
|
|
675
|
+
'data-vmz-props',
|
|
676
|
+
'data-vmz-layout',
|
|
677
|
+
'data-vmz-route',
|
|
678
|
+
'data-vmz-locale',
|
|
679
|
+
'data-vmz-dir',
|
|
680
|
+
]) {
|
|
681
|
+
const v = nextApp.getAttribute(name);
|
|
682
|
+
if (v == null)
|
|
683
|
+
root.removeAttribute(name);
|
|
684
|
+
else
|
|
685
|
+
root.setAttribute(name, v);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
178
688
|
/**
|
|
179
689
|
* @param {string} html
|
|
180
690
|
* @param {Document} doc
|