@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.
@@ -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 #app, hydrate.
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 fetchImpl = opts.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
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
- async function transitionTo(url, { replace = false, fromPop = 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 } = {}) {
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
- // Fall back to full navigation on hard errors.
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
- // Dispose previous Direct instance if present.
73
- const prev = root.__vmzInst;
74
- if (prev && typeof opts.destroy === 'function') {
75
- opts.destroy(prev);
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 = fresh.getAttribute('data-vmz-props');
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 (chunkId) {
102
- const importChunk = opts.importPage || (async (id) => (await import(/* @vite-ignore */ `/${id}.client.js`)).default);
103
- const Page = await importChunk(chunkId);
104
- const layoutChain = (fresh.getAttribute('data-vmz-layout') || '')
105
- .split(',')
106
- .map((s) => s.trim())
107
- .filter(Boolean);
108
- /** @type {any[]} */
109
- const layoutCtors = [];
110
- for (const id of layoutChain) {
111
- layoutCtors.push(await importChunk(id));
112
- }
113
- const dom = await import(/* @vite-ignore */ '/vmz-dom.js');
114
- const hydrateRoute = opts.hydrateRoute || dom.hydrateRoute;
115
- if (typeof hydrateRoute === 'function') {
116
- await hydrateRoute(Page, fresh, props, layoutCtors);
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
- else {
119
- const hydrate = opts.hydrate || dom.hydrate;
120
- await hydrate(Page, fresh, props);
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
- return { ok: true, href: target.pathname + target.search, chunkId };
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
- void transitionTo(u.pathname + u.search + u.hash, {
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