@vmz/core 0.0.3 → 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.
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Browser client navigation — same-app `<Link>` SPA takeover.
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 page (retain layout
5
+ * when `data-vmz-layout` is unchanged) or full `#app`, then hydrate.
6
+ */
7
+ // @ts-nocheck
8
+ /**
9
+ * @param {{
10
+ * fetchImpl?: typeof fetch,
11
+ * document?: Document,
12
+ * history?: History,
13
+ * location?: Location,
14
+ * hydrate?: (Ctor: any, root: Element, props: object) => Promise<unknown>,
15
+ * hydrateRoute?: (Page: any, root: Element, props: object, layouts?: any[]) => Promise<unknown>,
16
+ * hydrateRoutePage?: (Page: any, root: Element, props: object) => Promise<unknown>,
17
+ * destroy?: (inst: object) => void,
18
+ * importPage?: (chunkId: string) => Promise<any>,
19
+ * }} [opts]
20
+ */
21
+ export function installClientNavigation(opts = {}) {
22
+ const doc = opts.document || (typeof document !== 'undefined' ? document : null);
23
+ const win = typeof window !== 'undefined' ? window : null;
24
+ const hist = opts.history || win?.history;
25
+ const loc = opts.location || win?.location;
26
+ const fetchImplDefault = opts.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
27
+ /** @type {typeof fetch | null} */
28
+ let fetchImpl = fetchImplDefault;
29
+ if (!doc || !hist || !loc || !fetchImpl) {
30
+ return { ok: false, reason: 'missing document/history/fetch' };
31
+ }
32
+ if (win && !win.__vmzBootId) {
33
+ win.__vmzBootId = `boot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
34
+ }
35
+ if (win)
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
+ }
45
+ /** @type {AbortController | null} */
46
+ let inflight = null;
47
+ let navigating = false;
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 } = {}) {
180
+ const target = new URL(url, loc.href);
181
+ if (target.origin !== loc.origin) {
182
+ loc.assign(target.href);
183
+ return { ok: false, reason: 'cross-origin' };
184
+ }
185
+ if (!fromPop)
186
+ saveScroll();
187
+ if (inflight)
188
+ inflight.abort();
189
+ inflight = new AbortController();
190
+ const signal = inflight.signal;
191
+ navigating = true;
192
+ try {
193
+ const res = await fetchImpl(target.pathname + target.search, {
194
+ method: 'GET',
195
+ headers: { accept: 'text/html', 'x-vmz-client-nav': '1' },
196
+ signal,
197
+ });
198
+ if (!res.ok) {
199
+ // LocaleTransition uses softFail — keep current surface (no full assign).
200
+ if (!fromPop && !softFail)
201
+ loc.assign(target.href);
202
+ return { ok: false, reason: `http ${res.status}` };
203
+ }
204
+ const html = await res.text();
205
+ const nextApp = extractAppHtml(html, doc);
206
+ if (!nextApp) {
207
+ if (!fromPop && !softFail)
208
+ loc.assign(target.href);
209
+ return { ok: false, reason: 'missing #app in response' };
210
+ }
211
+ const root = doc.getElementById('app');
212
+ if (!root) {
213
+ if (!fromPop && !softFail)
214
+ loc.assign(target.href);
215
+ return { ok: false, reason: 'missing #app' };
216
+ }
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') || '';
221
+ let props = {};
222
+ try {
223
+ const raw = nextApp.getAttribute('data-vmz-props');
224
+ if (raw)
225
+ props = JSON.parse(raw);
226
+ }
227
+ catch {
228
+ /* ignore */
229
+ }
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
+ }
266
+ }
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
+ }
304
+ }
305
+ }
306
+ const localeId = applyLocaleRealization(liveRoot);
307
+ const focusTarget = restoreFocus(liveRoot, target);
308
+ if (win) {
309
+ win.__vmzClientNavCount = (win.__vmzClientNavCount || 0) + 1;
310
+ win.__vmzLastClientNav = {
311
+ href: target.pathname + target.search,
312
+ routeId: liveRoot?.getAttribute?.('data-vmz-route') || nextApp.getAttribute?.('data-vmz-route') || null,
313
+ chunkId,
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,
321
+ };
322
+ }
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
+ };
343
+ }
344
+ finally {
345
+ navigating = false;
346
+ }
347
+ }
348
+ function onClick(ev) {
349
+ if (navigating)
350
+ return;
351
+ if (ev.defaultPrevented)
352
+ return;
353
+ if (ev.button !== 0)
354
+ return;
355
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)
356
+ return;
357
+ const a = ev.target?.closest?.('a[data-vmz-route][href]');
358
+ if (!a)
359
+ return;
360
+ const href = a.getAttribute('href');
361
+ if (!href || href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
362
+ return;
363
+ const u = new URL(href, loc.href);
364
+ if (u.origin !== loc.origin)
365
+ return;
366
+ // Download / new tab
367
+ if (a.hasAttribute('download') || a.getAttribute('target') === '_blank')
368
+ return;
369
+ ev.preventDefault();
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, {
373
+ replace: a.getAttribute('data-vmz-replace') === 'true',
374
+ });
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
+ }
430
+ function onPopState() {
431
+ void transitionTo(loc.pathname + loc.search + loc.hash, { fromPop: true });
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
+ }
602
+ doc.addEventListener('click', onClick);
603
+ win?.addEventListener?.('popstate', onPopState);
604
+ return {
605
+ ok: true,
606
+ transitionTo,
607
+ transitionLocale,
608
+ dispose() {
609
+ doc.removeEventListener('click', onClick);
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
+ }
627
+ },
628
+ };
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
+ }
688
+ /**
689
+ * @param {string} html
690
+ * @param {Document} doc
691
+ */
692
+ export function extractAppHtml(html, doc) {
693
+ const parser = new (doc.defaultView?.DOMParser || globalThis.DOMParser)();
694
+ const parsed = parser.parseFromString(html, 'text/html');
695
+ return parsed.getElementById('app');
696
+ }