@moku-labs/web 1.5.3 → 1.6.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.
@@ -1550,15 +1550,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
1550
1550
  * directly: no `app.router` reference, no manual "bind", no module global, no
1551
1551
  * "not bound" guard, and no createApp ↔ routes cycle.
1552
1552
  *
1553
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
1554
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
1555
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
1556
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
1557
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
1558
+ * prefixed.
1559
+ *
1553
1560
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
1561
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
1554
1562
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
1555
1563
  * @example
1556
1564
  * ```ts
1557
- * const url = createUrls(routes);
1558
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
1565
+ * const url = createUrls(routes, "en");
1566
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
1567
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
1559
1568
  * ```
1560
1569
  */
1561
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
1570
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
1562
1571
  //#endregion
1563
1572
  //#region src/plugins/router/index.d.ts
1564
1573
  /**
package/dist/browser.mjs CHANGED
@@ -1587,15 +1587,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
1587
1587
  * param is absent has its segment skipped entirely (no empty segment), so a missing
1588
1588
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
1589
1589
  *
1590
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
1591
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
1592
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
1593
+ *
1590
1594
  * @param pattern - The route pattern.
1591
1595
  * @param params - Param values to substitute.
1596
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
1592
1597
  * @returns The resolved relative URL string.
1593
1598
  * @example
1594
1599
  * ```ts
1595
1600
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
1601
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
1602
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
1596
1603
  * ```
1597
1604
  */
1598
- function buildUrl(pattern, params) {
1605
+ function buildUrl(pattern, params, defaultLocale) {
1599
1606
  const out = [];
1600
1607
  for (const segment of pattern.split("/")) {
1601
1608
  const placeholder = parsePlaceholder(segment);
@@ -1605,6 +1612,7 @@ function buildUrl(pattern, params) {
1605
1612
  }
1606
1613
  const value = params[placeholder.name] ?? "";
1607
1614
  if (placeholder.optional && value === "") continue;
1615
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
1608
1616
  out.push(value);
1609
1617
  }
1610
1618
  return out.join("/");
@@ -1614,14 +1622,15 @@ function buildUrl(pattern, params) {
1614
1622
  *
1615
1623
  * @param pattern - The route pattern.
1616
1624
  * @param params - Param values to substitute.
1625
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
1617
1626
  * @returns The output file path, e.g. `hello/index.html`.
1618
1627
  * @example
1619
1628
  * ```ts
1620
1629
  * buildFilePath("/{slug}/", { slug: "hello" });
1621
1630
  * ```
1622
1631
  */
1623
- function buildFilePath(pattern, params) {
1624
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
1632
+ function buildFilePath(pattern, params, defaultLocale) {
1633
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
1625
1634
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
1626
1635
  }
1627
1636
  /**
@@ -1650,15 +1659,16 @@ function buildMatchers(pattern, locales) {
1650
1659
  * pattern.
1651
1660
  *
1652
1661
  * @param pattern - The route pattern bound into the closure.
1662
+ * @param defaultLocale - The locale served bare (bound into the closure).
1653
1663
  * @returns A function mapping params to the resolved relative URL.
1654
1664
  * @example
1655
1665
  * ```ts
1656
- * const toUrl = createToUrlFn("/{slug}/");
1666
+ * const toUrl = createToUrlFn("/{slug}/", "en");
1657
1667
  * toUrl({ slug: "x" }); // "/x/"
1658
1668
  * ```
1659
1669
  */
1660
- function createToUrlFunction(pattern) {
1661
- return (params) => buildUrl(pattern, params);
1670
+ function createToUrlFunction(pattern, defaultLocale) {
1671
+ return (params) => buildUrl(pattern, params, defaultLocale);
1662
1672
  }
1663
1673
  /**
1664
1674
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -1667,15 +1677,16 @@ function createToUrlFunction(pattern) {
1667
1677
  *
1668
1678
  * @param pattern - The route pattern bound into the closure.
1669
1679
  * @param definition - The route definition carrying any `toFile` override.
1680
+ * @param defaultLocale - The locale served bare (bound into the closure).
1670
1681
  * @returns A function mapping params to the output file path.
1671
1682
  * @example
1672
1683
  * ```ts
1673
- * const toFile = createToFileFn("/{slug}/", definition);
1684
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
1674
1685
  * toFile({ slug: "x" }); // "x/index.html"
1675
1686
  * ```
1676
1687
  */
1677
- function createToFileFunction(pattern, definition) {
1678
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1688
+ function createToFileFunction(pattern, definition, defaultLocale) {
1689
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
1679
1690
  }
1680
1691
  /**
1681
1692
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -1692,8 +1703,8 @@ function createToFileFunction(pattern, definition) {
1692
1703
  function compileRoute(name, definition, input) {
1693
1704
  const { pattern } = definition;
1694
1705
  const matchers = buildMatchers(pattern, input.locales);
1695
- const toUrl = createToUrlFunction(pattern);
1696
- const toFile = createToFileFunction(pattern, definition);
1706
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
1707
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
1697
1708
  return {
1698
1709
  name,
1699
1710
  pattern,
@@ -2124,15 +2135,24 @@ function defineRoutes(routes) {
2124
2135
  * directly: no `app.router` reference, no manual "bind", no module global, no
2125
2136
  * "not bound" guard, and no createApp ↔ routes cycle.
2126
2137
  *
2138
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2139
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2140
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2141
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2142
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2143
+ * prefixed.
2144
+ *
2127
2145
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2146
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2128
2147
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2129
2148
  * @example
2130
2149
  * ```ts
2131
- * const url = createUrls(routes);
2132
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2150
+ * const url = createUrls(routes, "en");
2151
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2152
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2133
2153
  * ```
2134
2154
  */
2135
- function createUrls(routes) {
2155
+ function createUrls(routes, defaultLocale) {
2136
2156
  return {
2137
2157
  /**
2138
2158
  * Build a route's URL path from its name and params.
@@ -2143,13 +2163,13 @@ function createUrls(routes) {
2143
2163
  * @throws {Error} If `name` is not present in the route map.
2144
2164
  * @example
2145
2165
  * ```ts
2146
- * url.toUrl("home", { lang: "en" }); // "/en/"
2166
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2147
2167
  * ```
2148
2168
  */
2149
2169
  toUrl(name, params = {}) {
2150
2170
  const definition = routes[name];
2151
2171
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2152
- return buildUrl(definition.pattern, params);
2172
+ return buildUrl(definition.pattern, params, defaultLocale);
2153
2173
  } };
2154
2174
  }
2155
2175
  //#endregion
@@ -3616,15 +3636,28 @@ async function performNavigation(pathname, handlers) {
3616
3636
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
3617
3637
  * enabled and supported (instant swap otherwise — never throws).
3618
3638
  *
3639
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
3640
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
3641
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
3642
+ * the destination scroll at this moment means the old and new snapshots share the
3643
+ * same scrollY, so there is no scroll delta for the transition to animate and a
3644
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
3645
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
3646
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
3647
+ *
3619
3648
  * @param doSwap - The synchronous DOM mutation to perform.
3620
3649
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
3650
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
3651
+ * (e.g. scroll to the destination position).
3621
3652
  * @example
3622
- * runSwap(() => current.replaceWith(next), true);
3653
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
3623
3654
  */
3624
- function runSwap(doSwap, viewTransitions) {
3655
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
3625
3656
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
3626
3657
  const docWithVt = document;
3627
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
3658
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
3659
+ beforeCapture?.();
3660
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
3628
3661
  else doSwap();
3629
3662
  }
3630
3663
  /**
@@ -3637,17 +3670,19 @@ function runSwap(doSwap, viewTransitions) {
3637
3670
  * @param swapSelector - CSS selector for the region to replace.
3638
3671
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
3639
3672
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
3673
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
3674
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
3640
3675
  * @example
3641
3676
  * swapRegion(doc, "main > section", false, () => mountNew());
3642
3677
  */
3643
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
3678
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
3644
3679
  const newContent = doc.querySelector(swapSelector);
3645
3680
  const currentContent = document.querySelector(swapSelector);
3646
3681
  if (!newContent || !currentContent) return;
3647
3682
  runSwap(() => {
3648
3683
  currentContent.replaceWith(newContent);
3649
3684
  onSwapped();
3650
- }, viewTransitions);
3685
+ }, viewTransitions, beforeCapture);
3651
3686
  }
3652
3687
  /**
3653
3688
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -3702,7 +3737,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3702
3737
  }
3703
3738
  saveScrollPosition(location.pathname);
3704
3739
  history.pushState({ scrollY: 0 }, "", url.pathname);
3705
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
3740
+ navigate(url.pathname).catch(() => {});
3706
3741
  };
3707
3742
  /**
3708
3743
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -3711,7 +3746,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3711
3746
  * globalThis.addEventListener("popstate", onPopState);
3712
3747
  */
3713
3748
  const onPopState = () => {
3714
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
3749
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
3715
3750
  };
3716
3751
  document.addEventListener("click", onClick);
3717
3752
  globalThis.addEventListener("popstate", onPopState);
@@ -3755,9 +3790,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
3755
3790
  navEvent.intercept({
3756
3791
  scroll: "manual",
3757
3792
  handler: async () => {
3758
- await navigate(url.pathname);
3759
- if (navEvent.navigationType === "traverse") navEvent.scroll();
3760
- else window.scrollTo(0, 0);
3793
+ if (navEvent.navigationType === "traverse") {
3794
+ await navigate(url.pathname, false);
3795
+ navEvent.scroll();
3796
+ } else await navigate(url.pathname);
3761
3797
  }
3762
3798
  });
3763
3799
  };
@@ -3916,6 +3952,25 @@ function syncDataHead(route, routeContext) {
3916
3952
  function createSpaKernel(state, config, emit, deps) {
3917
3953
  const resolved = resolveSpaConfig(config);
3918
3954
  let progress;
3955
+ let pendingScrollToTop = true;
3956
+ /**
3957
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
3958
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
3959
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
3960
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
3961
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
3962
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
3963
+ * saved position after the swap instead.
3964
+ *
3965
+ * @example
3966
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
3967
+ */
3968
+ const applyPendingScroll = () => {
3969
+ if (pendingScrollToTop) window.scrollTo({
3970
+ top: 0,
3971
+ behavior: "instant"
3972
+ });
3973
+ };
3919
3974
  /**
3920
3975
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3921
3976
  *
@@ -3931,7 +3986,7 @@ function createSpaKernel(state, config, emit, deps) {
3931
3986
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3932
3987
  scanAndMount(state, emit, resolved.swapSelector);
3933
3988
  notifyNavEnd(state);
3934
- });
3989
+ }, applyPendingScroll);
3935
3990
  state.currentUrl = pathname;
3936
3991
  progress?.done();
3937
3992
  emit("spa:navigated", { url: pathname });
@@ -4026,7 +4081,7 @@ function createSpaKernel(state, config, emit, deps) {
4026
4081
  *
4027
4082
  * @example
4028
4083
  * ```ts
4029
- * runSwap(renderAndMount, resolved.viewTransitions);
4084
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4030
4085
  * ```
4031
4086
  */
4032
4087
  const renderAndMount = () => {
@@ -4034,7 +4089,7 @@ function createSpaKernel(state, config, emit, deps) {
4034
4089
  scanAndMount(state, emit, resolved.swapSelector);
4035
4090
  notifyNavEnd(state);
4036
4091
  };
4037
- runSwap(renderAndMount, resolved.viewTransitions);
4092
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4038
4093
  state.currentUrl = pathname;
4039
4094
  progress?.done();
4040
4095
  emit("spa:navigated", { url: pathname });
@@ -4071,11 +4126,14 @@ function createSpaKernel(state, config, emit, deps) {
4071
4126
  * navigation entry point (Navigation API, History, programmatic) goes through it.
4072
4127
  *
4073
4128
  * @param pathname - The destination pathname.
4129
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
4130
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
4074
4131
  * @returns A promise resolving once the swap (or fallback) is dispatched.
4075
4132
  * @example
4076
4133
  * await navigate("/en/world/");
4077
4134
  */
4078
- const navigate = async (pathname) => {
4135
+ const navigate = async (pathname, scrollToTop = true) => {
4136
+ pendingScrollToTop = scrollToTop;
4079
4137
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
4080
4138
  await performNavigation(pathname, handlers);
4081
4139
  };
@@ -4424,10 +4482,31 @@ function contentApi(ctx) {
4424
4482
  */
4425
4483
  async function resolveArticle(ctx, slug, locale) {
4426
4484
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
4427
- if (native !== null) return native;
4485
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
4428
4486
  const fallbackLocale = ctx.defaultLocale();
4429
4487
  if (fallbackLocale === locale) return null;
4430
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4488
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4489
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
4490
+ }
4491
+ /**
4492
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
4493
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
4494
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
4495
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
4496
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
4497
+ *
4498
+ * @param ctx - Kernel-free domain context (i18n helpers).
4499
+ * @param article - The resolved article (mutated in place).
4500
+ * @returns The same article, with a bare `url` when it is the default locale.
4501
+ * @example
4502
+ * ```ts
4503
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
4504
+ * ```
4505
+ */
4506
+ function bareDefaultLocaleUrl(ctx, article) {
4507
+ const prefix = `/${ctx.defaultLocale()}`;
4508
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
4509
+ return article;
4431
4510
  }
4432
4511
  /**
4433
4512
  * Comparator sorting articles by frontmatter date descending (newest first),
package/dist/index.cjs CHANGED
@@ -1232,10 +1232,31 @@ function contentApi(ctx) {
1232
1232
  */
1233
1233
  async function resolveArticle(ctx, slug, locale) {
1234
1234
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
1235
- if (native !== null) return native;
1235
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
1236
1236
  const fallbackLocale = ctx.defaultLocale();
1237
1237
  if (fallbackLocale === locale) return null;
1238
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1238
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1239
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
1240
+ }
1241
+ /**
1242
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
1243
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
1244
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
1245
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
1246
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
1247
+ *
1248
+ * @param ctx - Kernel-free domain context (i18n helpers).
1249
+ * @param article - The resolved article (mutated in place).
1250
+ * @returns The same article, with a bare `url` when it is the default locale.
1251
+ * @example
1252
+ * ```ts
1253
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
1254
+ * ```
1255
+ */
1256
+ function bareDefaultLocaleUrl(ctx, article) {
1257
+ const prefix = `/${ctx.defaultLocale()}`;
1258
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
1259
+ return article;
1239
1260
  }
1240
1261
  /**
1241
1262
  * Comparator sorting articles by frontmatter date descending (newest first),
@@ -2145,15 +2166,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2145
2166
  * param is absent has its segment skipped entirely (no empty segment), so a missing
2146
2167
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
2147
2168
  *
2169
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
2170
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
2171
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
2172
+ *
2148
2173
  * @param pattern - The route pattern.
2149
2174
  * @param params - Param values to substitute.
2175
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2150
2176
  * @returns The resolved relative URL string.
2151
2177
  * @example
2152
2178
  * ```ts
2153
2179
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2180
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2181
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2154
2182
  * ```
2155
2183
  */
2156
- function buildUrl(pattern, params) {
2184
+ function buildUrl(pattern, params, defaultLocale) {
2157
2185
  const out = [];
2158
2186
  for (const segment of pattern.split("/")) {
2159
2187
  const placeholder = parsePlaceholder(segment);
@@ -2163,6 +2191,7 @@ function buildUrl(pattern, params) {
2163
2191
  }
2164
2192
  const value = params[placeholder.name] ?? "";
2165
2193
  if (placeholder.optional && value === "") continue;
2194
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2166
2195
  out.push(value);
2167
2196
  }
2168
2197
  return out.join("/");
@@ -2172,14 +2201,15 @@ function buildUrl(pattern, params) {
2172
2201
  *
2173
2202
  * @param pattern - The route pattern.
2174
2203
  * @param params - Param values to substitute.
2204
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2175
2205
  * @returns The output file path, e.g. `hello/index.html`.
2176
2206
  * @example
2177
2207
  * ```ts
2178
2208
  * buildFilePath("/{slug}/", { slug: "hello" });
2179
2209
  * ```
2180
2210
  */
2181
- function buildFilePath(pattern, params) {
2182
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
2211
+ function buildFilePath(pattern, params, defaultLocale) {
2212
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2183
2213
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2184
2214
  }
2185
2215
  /**
@@ -2208,15 +2238,16 @@ function buildMatchers(pattern, locales) {
2208
2238
  * pattern.
2209
2239
  *
2210
2240
  * @param pattern - The route pattern bound into the closure.
2241
+ * @param defaultLocale - The locale served bare (bound into the closure).
2211
2242
  * @returns A function mapping params to the resolved relative URL.
2212
2243
  * @example
2213
2244
  * ```ts
2214
- * const toUrl = createToUrlFn("/{slug}/");
2245
+ * const toUrl = createToUrlFn("/{slug}/", "en");
2215
2246
  * toUrl({ slug: "x" }); // "/x/"
2216
2247
  * ```
2217
2248
  */
2218
- function createToUrlFunction(pattern) {
2219
- return (params) => buildUrl(pattern, params);
2249
+ function createToUrlFunction(pattern, defaultLocale) {
2250
+ return (params) => buildUrl(pattern, params, defaultLocale);
2220
2251
  }
2221
2252
  /**
2222
2253
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -2225,15 +2256,16 @@ function createToUrlFunction(pattern) {
2225
2256
  *
2226
2257
  * @param pattern - The route pattern bound into the closure.
2227
2258
  * @param definition - The route definition carrying any `toFile` override.
2259
+ * @param defaultLocale - The locale served bare (bound into the closure).
2228
2260
  * @returns A function mapping params to the output file path.
2229
2261
  * @example
2230
2262
  * ```ts
2231
- * const toFile = createToFileFn("/{slug}/", definition);
2263
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
2232
2264
  * toFile({ slug: "x" }); // "x/index.html"
2233
2265
  * ```
2234
2266
  */
2235
- function createToFileFunction(pattern, definition) {
2236
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
2267
+ function createToFileFunction(pattern, definition, defaultLocale) {
2268
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
2237
2269
  }
2238
2270
  /**
2239
2271
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -2250,8 +2282,8 @@ function createToFileFunction(pattern, definition) {
2250
2282
  function compileRoute(name, definition, input) {
2251
2283
  const { pattern } = definition;
2252
2284
  const matchers = buildMatchers(pattern, input.locales);
2253
- const toUrl = createToUrlFunction(pattern);
2254
- const toFile = createToFileFunction(pattern, definition);
2285
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
2286
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
2255
2287
  return {
2256
2288
  name,
2257
2289
  pattern,
@@ -2682,15 +2714,24 @@ function defineRoutes(routes) {
2682
2714
  * directly: no `app.router` reference, no manual "bind", no module global, no
2683
2715
  * "not bound" guard, and no createApp ↔ routes cycle.
2684
2716
  *
2717
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2718
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2719
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2720
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2721
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2722
+ * prefixed.
2723
+ *
2685
2724
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2725
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2686
2726
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2687
2727
  * @example
2688
2728
  * ```ts
2689
- * const url = createUrls(routes);
2690
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2729
+ * const url = createUrls(routes, "en");
2730
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2731
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2691
2732
  * ```
2692
2733
  */
2693
- function createUrls(routes) {
2734
+ function createUrls(routes, defaultLocale) {
2694
2735
  return {
2695
2736
  /**
2696
2737
  * Build a route's URL path from its name and params.
@@ -2701,13 +2742,13 @@ function createUrls(routes) {
2701
2742
  * @throws {Error} If `name` is not present in the route map.
2702
2743
  * @example
2703
2744
  * ```ts
2704
- * url.toUrl("home", { lang: "en" }); // "/en/"
2745
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2705
2746
  * ```
2706
2747
  */
2707
2748
  toUrl(name, params = {}) {
2708
2749
  const definition = routes[name];
2709
2750
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2710
- return buildUrl(definition.pattern, params);
2751
+ return buildUrl(definition.pattern, params, defaultLocale);
2711
2752
  } };
2712
2753
  }
2713
2754
  //#endregion
@@ -5117,7 +5158,24 @@ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
5117
5158
  * ```
5118
5159
  */
5119
5160
  async function writeDocument(outDir, entry, params, html) {
5120
- const filePath = node_path.default.join(outDir, entry.toFile(params));
5161
+ await writeDocumentAt(outDir, entry.toFile(params), html);
5162
+ }
5163
+ /**
5164
+ * Write an HTML document to an explicit relative path under `outDir`, creating parent
5165
+ * directories first. Backs both the canonical page path ({@link writeDocument}) and the
5166
+ * default-locale `/{defaultLocale}/…` alias copy ({@link renderInstance}).
5167
+ *
5168
+ * @param outDir - The build output directory.
5169
+ * @param relativeFile - The output file path relative to `outDir` (e.g. `en/index.html`).
5170
+ * @param html - The complete HTML document to write.
5171
+ * @returns A promise resolved once the file is written.
5172
+ * @example
5173
+ * ```ts
5174
+ * await writeDocumentAt("dist", "en/about/index.html", "<!DOCTYPE html>…");
5175
+ * ```
5176
+ */
5177
+ async function writeDocumentAt(outDir, relativeFile, html) {
5178
+ const filePath = node_path.default.join(outDir, relativeFile);
5121
5179
  await (0, node_fs_promises.mkdir)(node_path.default.dirname(filePath), { recursive: true });
5122
5180
  await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
5123
5181
  }
@@ -5127,14 +5185,19 @@ async function writeDocument(outDir, entry, params, html) {
5127
5185
  * `<head>`/body → assemble the document (template fill or in-code shell) → write.
5128
5186
  * Uses the configured shell `template` when supplied, otherwise the in-code shell.
5129
5187
  *
5188
+ * The default locale is served at BARE paths, so each default-locale page on a
5189
+ * `{lang:?}` route is ALSO written to `/{defaultLocale}/…` — the SAME rendered HTML (its
5190
+ * canonical already points at the bare URL) — so an explicit `/{defaultLocale}/…` link
5191
+ * serves the page directly with no redirect. Both pages are returned so each gets a sidecar.
5192
+ *
5130
5193
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
5131
5194
  * @param instance - The concrete page instance to render.
5132
- * @param shell - Per-build wiring shared across instances (asset tags + template).
5195
+ * @param shell - Per-build wiring shared across instances (asset tags + template + default locale).
5133
5196
  * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
5134
- * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
5197
+ * @returns The rendered page(s): the canonical page, plus the `/{defaultLocale}/` alias when emitted.
5135
5198
  * @example
5136
5199
  * ```ts
5137
- * await renderInstance(ctx, instance, { assets: "", template: null }, false);
5200
+ * await renderInstance(ctx, instance, shell, false);
5138
5201
  * ```
5139
5202
  */
5140
5203
  async function renderInstance(ctx, instance, shell, reuse) {
@@ -5156,20 +5219,31 @@ async function renderInstance(ctx, instance, shell, reuse) {
5156
5219
  };
5157
5220
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
5158
5221
  await writeDocument(ctx.config.outDir, entry, params, html);
5159
- return {
5222
+ const clientNavigable = definition._handlers.render !== void 0;
5223
+ const pages = [{
5160
5224
  url,
5161
5225
  html,
5162
5226
  data,
5163
- clientNavigable: definition._handlers.render !== void 0
5164
- };
5227
+ clientNavigable
5228
+ }];
5229
+ if (locale === shell.defaultLocale && entry.pattern.includes("{lang:?}")) {
5230
+ await writeDocumentAt(ctx.config.outDir, `${shell.defaultLocale}/${entry.toFile(params)}`, html);
5231
+ pages.push({
5232
+ url: `/${shell.defaultLocale}${url}`,
5233
+ html,
5234
+ data,
5235
+ clientNavigable
5236
+ });
5237
+ }
5238
+ return pages;
5165
5239
  }
5166
5240
  /**
5167
5241
  * Prepare the per-build {@link RenderShell} ONCE (O(1) per page): read the optional
5168
5242
  * shell `template` from disk when configured + present, and precompute the injected
5169
5243
  * asset tags. `template` is `null` when unset/missing (use the in-code shell).
5170
5244
  *
5171
- * @param ctx - Plugin context (provides `config`, `state`).
5172
- * @returns The shared shell wiring (asset tags + template-or-null) for every page.
5245
+ * @param ctx - Plugin context (provides `config`, `state`, `require`).
5246
+ * @returns The shared shell wiring (asset tags + template-or-null + default locale) for every page.
5173
5247
  * @example
5174
5248
  * ```ts
5175
5249
  * const shell = await prepareShell(ctx);
@@ -5180,7 +5254,8 @@ async function prepareShell(ctx) {
5180
5254
  const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
5181
5255
  return {
5182
5256
  assets: buildAssetTags(ctx),
5183
- template
5257
+ template,
5258
+ defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5184
5259
  };
5185
5260
  }
5186
5261
  /**
@@ -5314,7 +5389,7 @@ async function renderPages(ctx, options) {
5314
5389
  const byPattern = makeEntryMap(router);
5315
5390
  if (!reuse) ctx.state.renderCache.clear();
5316
5391
  const shell = await prepareShell(ctx);
5317
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
5392
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5318
5393
  await writeDataSidecars(ctx, rendered, router.mode());
5319
5394
  ctx.log.debug("build:pages", { count: rendered.length });
5320
5395
  return {
@@ -10037,15 +10112,28 @@ async function performNavigation(pathname, handlers) {
10037
10112
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
10038
10113
  * enabled and supported (instant swap otherwise — never throws).
10039
10114
  *
10115
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
10116
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
10117
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
10118
+ * the destination scroll at this moment means the old and new snapshots share the
10119
+ * same scrollY, so there is no scroll delta for the transition to animate and a
10120
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
10121
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
10122
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
10123
+ *
10040
10124
  * @param doSwap - The synchronous DOM mutation to perform.
10041
10125
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10126
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10127
+ * (e.g. scroll to the destination position).
10042
10128
  * @example
10043
- * runSwap(() => current.replaceWith(next), true);
10129
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
10044
10130
  */
10045
- function runSwap(doSwap, viewTransitions) {
10131
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
10046
10132
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
10047
10133
  const docWithVt = document;
10048
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
10134
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
10135
+ beforeCapture?.();
10136
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
10049
10137
  else doSwap();
10050
10138
  }
10051
10139
  /**
@@ -10058,17 +10146,19 @@ function runSwap(doSwap, viewTransitions) {
10058
10146
  * @param swapSelector - CSS selector for the region to replace.
10059
10147
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10060
10148
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10149
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10150
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10061
10151
  * @example
10062
10152
  * swapRegion(doc, "main > section", false, () => mountNew());
10063
10153
  */
10064
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
10154
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10065
10155
  const newContent = doc.querySelector(swapSelector);
10066
10156
  const currentContent = document.querySelector(swapSelector);
10067
10157
  if (!newContent || !currentContent) return;
10068
10158
  runSwap(() => {
10069
10159
  currentContent.replaceWith(newContent);
10070
10160
  onSwapped();
10071
- }, viewTransitions);
10161
+ }, viewTransitions, beforeCapture);
10072
10162
  }
10073
10163
  /**
10074
10164
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10123,7 +10213,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10123
10213
  }
10124
10214
  saveScrollPosition(location.pathname);
10125
10215
  history.pushState({ scrollY: 0 }, "", url.pathname);
10126
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
10216
+ navigate(url.pathname).catch(() => {});
10127
10217
  };
10128
10218
  /**
10129
10219
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10132,7 +10222,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10132
10222
  * globalThis.addEventListener("popstate", onPopState);
10133
10223
  */
10134
10224
  const onPopState = () => {
10135
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10225
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10136
10226
  };
10137
10227
  document.addEventListener("click", onClick);
10138
10228
  globalThis.addEventListener("popstate", onPopState);
@@ -10176,9 +10266,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10176
10266
  navEvent.intercept({
10177
10267
  scroll: "manual",
10178
10268
  handler: async () => {
10179
- await navigate(url.pathname);
10180
- if (navEvent.navigationType === "traverse") navEvent.scroll();
10181
- else window.scrollTo(0, 0);
10269
+ if (navEvent.navigationType === "traverse") {
10270
+ await navigate(url.pathname, false);
10271
+ navEvent.scroll();
10272
+ } else await navigate(url.pathname);
10182
10273
  }
10183
10274
  });
10184
10275
  };
@@ -10337,6 +10428,25 @@ function syncDataHead(route, routeContext) {
10337
10428
  function createSpaKernel(state, config, emit, deps) {
10338
10429
  const resolved = resolveSpaConfig(config);
10339
10430
  let progress;
10431
+ let pendingScrollToTop = true;
10432
+ /**
10433
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
10434
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
10435
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
10436
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
10437
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
10438
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
10439
+ * saved position after the swap instead.
10440
+ *
10441
+ * @example
10442
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10443
+ */
10444
+ const applyPendingScroll = () => {
10445
+ if (pendingScrollToTop) window.scrollTo({
10446
+ top: 0,
10447
+ behavior: "instant"
10448
+ });
10449
+ };
10340
10450
  /**
10341
10451
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10342
10452
  *
@@ -10352,7 +10462,7 @@ function createSpaKernel(state, config, emit, deps) {
10352
10462
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10353
10463
  scanAndMount(state, emit, resolved.swapSelector);
10354
10464
  notifyNavEnd(state);
10355
- });
10465
+ }, applyPendingScroll);
10356
10466
  state.currentUrl = pathname;
10357
10467
  progress?.done();
10358
10468
  emit("spa:navigated", { url: pathname });
@@ -10447,7 +10557,7 @@ function createSpaKernel(state, config, emit, deps) {
10447
10557
  *
10448
10558
  * @example
10449
10559
  * ```ts
10450
- * runSwap(renderAndMount, resolved.viewTransitions);
10560
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10451
10561
  * ```
10452
10562
  */
10453
10563
  const renderAndMount = () => {
@@ -10455,7 +10565,7 @@ function createSpaKernel(state, config, emit, deps) {
10455
10565
  scanAndMount(state, emit, resolved.swapSelector);
10456
10566
  notifyNavEnd(state);
10457
10567
  };
10458
- runSwap(renderAndMount, resolved.viewTransitions);
10568
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10459
10569
  state.currentUrl = pathname;
10460
10570
  progress?.done();
10461
10571
  emit("spa:navigated", { url: pathname });
@@ -10492,11 +10602,14 @@ function createSpaKernel(state, config, emit, deps) {
10492
10602
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10493
10603
  *
10494
10604
  * @param pathname - The destination pathname.
10605
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10606
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10495
10607
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10496
10608
  * @example
10497
10609
  * await navigate("/en/world/");
10498
10610
  */
10499
- const navigate = async (pathname) => {
10611
+ const navigate = async (pathname, scrollToTop = true) => {
10612
+ pendingScrollToTop = scrollToTop;
10500
10613
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10501
10614
  await performNavigation(pathname, handlers);
10502
10615
  };
package/dist/index.d.cts CHANGED
@@ -3326,15 +3326,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
3326
3326
  * directly: no `app.router` reference, no manual "bind", no module global, no
3327
3327
  * "not bound" guard, and no createApp ↔ routes cycle.
3328
3328
  *
3329
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
3330
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
3331
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
3332
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
3333
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
3334
+ * prefixed.
3335
+ *
3329
3336
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
3337
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
3330
3338
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
3331
3339
  * @example
3332
3340
  * ```ts
3333
- * const url = createUrls(routes);
3334
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
3341
+ * const url = createUrls(routes, "en");
3342
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
3343
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
3335
3344
  * ```
3336
3345
  */
3337
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
3346
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
3338
3347
  //#endregion
3339
3348
  //#region src/plugins/router/index.d.ts
3340
3349
  /**
package/dist/index.d.mts CHANGED
@@ -3326,15 +3326,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
3326
3326
  * directly: no `app.router` reference, no manual "bind", no module global, no
3327
3327
  * "not bound" guard, and no createApp ↔ routes cycle.
3328
3328
  *
3329
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
3330
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
3331
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
3332
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
3333
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
3334
+ * prefixed.
3335
+ *
3329
3336
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
3337
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
3330
3338
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
3331
3339
  * @example
3332
3340
  * ```ts
3333
- * const url = createUrls(routes);
3334
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
3341
+ * const url = createUrls(routes, "en");
3342
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
3343
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
3335
3344
  * ```
3336
3345
  */
3337
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
3346
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
3338
3347
  //#endregion
3339
3348
  //#region src/plugins/router/index.d.ts
3340
3349
  /**
package/dist/index.mjs CHANGED
@@ -1219,10 +1219,31 @@ function contentApi(ctx) {
1219
1219
  */
1220
1220
  async function resolveArticle(ctx, slug, locale) {
1221
1221
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
1222
- if (native !== null) return native;
1222
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
1223
1223
  const fallbackLocale = ctx.defaultLocale();
1224
1224
  if (fallbackLocale === locale) return null;
1225
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1225
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1226
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
1227
+ }
1228
+ /**
1229
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
1230
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
1231
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
1232
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
1233
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
1234
+ *
1235
+ * @param ctx - Kernel-free domain context (i18n helpers).
1236
+ * @param article - The resolved article (mutated in place).
1237
+ * @returns The same article, with a bare `url` when it is the default locale.
1238
+ * @example
1239
+ * ```ts
1240
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
1241
+ * ```
1242
+ */
1243
+ function bareDefaultLocaleUrl(ctx, article) {
1244
+ const prefix = `/${ctx.defaultLocale()}`;
1245
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
1246
+ return article;
1226
1247
  }
1227
1248
  /**
1228
1249
  * Comparator sorting articles by frontmatter date descending (newest first),
@@ -2132,15 +2153,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2132
2153
  * param is absent has its segment skipped entirely (no empty segment), so a missing
2133
2154
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
2134
2155
  *
2156
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
2157
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
2158
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
2159
+ *
2135
2160
  * @param pattern - The route pattern.
2136
2161
  * @param params - Param values to substitute.
2162
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2137
2163
  * @returns The resolved relative URL string.
2138
2164
  * @example
2139
2165
  * ```ts
2140
2166
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2167
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2168
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2141
2169
  * ```
2142
2170
  */
2143
- function buildUrl(pattern, params) {
2171
+ function buildUrl(pattern, params, defaultLocale) {
2144
2172
  const out = [];
2145
2173
  for (const segment of pattern.split("/")) {
2146
2174
  const placeholder = parsePlaceholder(segment);
@@ -2150,6 +2178,7 @@ function buildUrl(pattern, params) {
2150
2178
  }
2151
2179
  const value = params[placeholder.name] ?? "";
2152
2180
  if (placeholder.optional && value === "") continue;
2181
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2153
2182
  out.push(value);
2154
2183
  }
2155
2184
  return out.join("/");
@@ -2159,14 +2188,15 @@ function buildUrl(pattern, params) {
2159
2188
  *
2160
2189
  * @param pattern - The route pattern.
2161
2190
  * @param params - Param values to substitute.
2191
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2162
2192
  * @returns The output file path, e.g. `hello/index.html`.
2163
2193
  * @example
2164
2194
  * ```ts
2165
2195
  * buildFilePath("/{slug}/", { slug: "hello" });
2166
2196
  * ```
2167
2197
  */
2168
- function buildFilePath(pattern, params) {
2169
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
2198
+ function buildFilePath(pattern, params, defaultLocale) {
2199
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2170
2200
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2171
2201
  }
2172
2202
  /**
@@ -2195,15 +2225,16 @@ function buildMatchers(pattern, locales) {
2195
2225
  * pattern.
2196
2226
  *
2197
2227
  * @param pattern - The route pattern bound into the closure.
2228
+ * @param defaultLocale - The locale served bare (bound into the closure).
2198
2229
  * @returns A function mapping params to the resolved relative URL.
2199
2230
  * @example
2200
2231
  * ```ts
2201
- * const toUrl = createToUrlFn("/{slug}/");
2232
+ * const toUrl = createToUrlFn("/{slug}/", "en");
2202
2233
  * toUrl({ slug: "x" }); // "/x/"
2203
2234
  * ```
2204
2235
  */
2205
- function createToUrlFunction(pattern) {
2206
- return (params) => buildUrl(pattern, params);
2236
+ function createToUrlFunction(pattern, defaultLocale) {
2237
+ return (params) => buildUrl(pattern, params, defaultLocale);
2207
2238
  }
2208
2239
  /**
2209
2240
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -2212,15 +2243,16 @@ function createToUrlFunction(pattern) {
2212
2243
  *
2213
2244
  * @param pattern - The route pattern bound into the closure.
2214
2245
  * @param definition - The route definition carrying any `toFile` override.
2246
+ * @param defaultLocale - The locale served bare (bound into the closure).
2215
2247
  * @returns A function mapping params to the output file path.
2216
2248
  * @example
2217
2249
  * ```ts
2218
- * const toFile = createToFileFn("/{slug}/", definition);
2250
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
2219
2251
  * toFile({ slug: "x" }); // "x/index.html"
2220
2252
  * ```
2221
2253
  */
2222
- function createToFileFunction(pattern, definition) {
2223
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
2254
+ function createToFileFunction(pattern, definition, defaultLocale) {
2255
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
2224
2256
  }
2225
2257
  /**
2226
2258
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -2237,8 +2269,8 @@ function createToFileFunction(pattern, definition) {
2237
2269
  function compileRoute(name, definition, input) {
2238
2270
  const { pattern } = definition;
2239
2271
  const matchers = buildMatchers(pattern, input.locales);
2240
- const toUrl = createToUrlFunction(pattern);
2241
- const toFile = createToFileFunction(pattern, definition);
2272
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
2273
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
2242
2274
  return {
2243
2275
  name,
2244
2276
  pattern,
@@ -2669,15 +2701,24 @@ function defineRoutes(routes) {
2669
2701
  * directly: no `app.router` reference, no manual "bind", no module global, no
2670
2702
  * "not bound" guard, and no createApp ↔ routes cycle.
2671
2703
  *
2704
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2705
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2706
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2707
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2708
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2709
+ * prefixed.
2710
+ *
2672
2711
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2712
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2673
2713
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2674
2714
  * @example
2675
2715
  * ```ts
2676
- * const url = createUrls(routes);
2677
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2716
+ * const url = createUrls(routes, "en");
2717
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2718
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2678
2719
  * ```
2679
2720
  */
2680
- function createUrls(routes) {
2721
+ function createUrls(routes, defaultLocale) {
2681
2722
  return {
2682
2723
  /**
2683
2724
  * Build a route's URL path from its name and params.
@@ -2688,13 +2729,13 @@ function createUrls(routes) {
2688
2729
  * @throws {Error} If `name` is not present in the route map.
2689
2730
  * @example
2690
2731
  * ```ts
2691
- * url.toUrl("home", { lang: "en" }); // "/en/"
2732
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2692
2733
  * ```
2693
2734
  */
2694
2735
  toUrl(name, params = {}) {
2695
2736
  const definition = routes[name];
2696
2737
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2697
- return buildUrl(definition.pattern, params);
2738
+ return buildUrl(definition.pattern, params, defaultLocale);
2698
2739
  } };
2699
2740
  }
2700
2741
  //#endregion
@@ -5104,7 +5145,24 @@ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
5104
5145
  * ```
5105
5146
  */
5106
5147
  async function writeDocument(outDir, entry, params, html) {
5107
- const filePath = path.join(outDir, entry.toFile(params));
5148
+ await writeDocumentAt(outDir, entry.toFile(params), html);
5149
+ }
5150
+ /**
5151
+ * Write an HTML document to an explicit relative path under `outDir`, creating parent
5152
+ * directories first. Backs both the canonical page path ({@link writeDocument}) and the
5153
+ * default-locale `/{defaultLocale}/…` alias copy ({@link renderInstance}).
5154
+ *
5155
+ * @param outDir - The build output directory.
5156
+ * @param relativeFile - The output file path relative to `outDir` (e.g. `en/index.html`).
5157
+ * @param html - The complete HTML document to write.
5158
+ * @returns A promise resolved once the file is written.
5159
+ * @example
5160
+ * ```ts
5161
+ * await writeDocumentAt("dist", "en/about/index.html", "<!DOCTYPE html>…");
5162
+ * ```
5163
+ */
5164
+ async function writeDocumentAt(outDir, relativeFile, html) {
5165
+ const filePath = path.join(outDir, relativeFile);
5108
5166
  await mkdir(path.dirname(filePath), { recursive: true });
5109
5167
  await writeFile(filePath, html, "utf8");
5110
5168
  }
@@ -5114,14 +5172,19 @@ async function writeDocument(outDir, entry, params, html) {
5114
5172
  * `<head>`/body → assemble the document (template fill or in-code shell) → write.
5115
5173
  * Uses the configured shell `template` when supplied, otherwise the in-code shell.
5116
5174
  *
5175
+ * The default locale is served at BARE paths, so each default-locale page on a
5176
+ * `{lang:?}` route is ALSO written to `/{defaultLocale}/…` — the SAME rendered HTML (its
5177
+ * canonical already points at the bare URL) — so an explicit `/{defaultLocale}/…` link
5178
+ * serves the page directly with no redirect. Both pages are returned so each gets a sidecar.
5179
+ *
5117
5180
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
5118
5181
  * @param instance - The concrete page instance to render.
5119
- * @param shell - Per-build wiring shared across instances (asset tags + template).
5182
+ * @param shell - Per-build wiring shared across instances (asset tags + template + default locale).
5120
5183
  * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
5121
- * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
5184
+ * @returns The rendered page(s): the canonical page, plus the `/{defaultLocale}/` alias when emitted.
5122
5185
  * @example
5123
5186
  * ```ts
5124
- * await renderInstance(ctx, instance, { assets: "", template: null }, false);
5187
+ * await renderInstance(ctx, instance, shell, false);
5125
5188
  * ```
5126
5189
  */
5127
5190
  async function renderInstance(ctx, instance, shell, reuse) {
@@ -5143,20 +5206,31 @@ async function renderInstance(ctx, instance, shell, reuse) {
5143
5206
  };
5144
5207
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
5145
5208
  await writeDocument(ctx.config.outDir, entry, params, html);
5146
- return {
5209
+ const clientNavigable = definition._handlers.render !== void 0;
5210
+ const pages = [{
5147
5211
  url,
5148
5212
  html,
5149
5213
  data,
5150
- clientNavigable: definition._handlers.render !== void 0
5151
- };
5214
+ clientNavigable
5215
+ }];
5216
+ if (locale === shell.defaultLocale && entry.pattern.includes("{lang:?}")) {
5217
+ await writeDocumentAt(ctx.config.outDir, `${shell.defaultLocale}/${entry.toFile(params)}`, html);
5218
+ pages.push({
5219
+ url: `/${shell.defaultLocale}${url}`,
5220
+ html,
5221
+ data,
5222
+ clientNavigable
5223
+ });
5224
+ }
5225
+ return pages;
5152
5226
  }
5153
5227
  /**
5154
5228
  * Prepare the per-build {@link RenderShell} ONCE (O(1) per page): read the optional
5155
5229
  * shell `template` from disk when configured + present, and precompute the injected
5156
5230
  * asset tags. `template` is `null` when unset/missing (use the in-code shell).
5157
5231
  *
5158
- * @param ctx - Plugin context (provides `config`, `state`).
5159
- * @returns The shared shell wiring (asset tags + template-or-null) for every page.
5232
+ * @param ctx - Plugin context (provides `config`, `state`, `require`).
5233
+ * @returns The shared shell wiring (asset tags + template-or-null + default locale) for every page.
5160
5234
  * @example
5161
5235
  * ```ts
5162
5236
  * const shell = await prepareShell(ctx);
@@ -5167,7 +5241,8 @@ async function prepareShell(ctx) {
5167
5241
  const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
5168
5242
  return {
5169
5243
  assets: buildAssetTags(ctx),
5170
- template
5244
+ template,
5245
+ defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5171
5246
  };
5172
5247
  }
5173
5248
  /**
@@ -5301,7 +5376,7 @@ async function renderPages(ctx, options) {
5301
5376
  const byPattern = makeEntryMap(router);
5302
5377
  if (!reuse) ctx.state.renderCache.clear();
5303
5378
  const shell = await prepareShell(ctx);
5304
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
5379
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5305
5380
  await writeDataSidecars(ctx, rendered, router.mode());
5306
5381
  ctx.log.debug("build:pages", { count: rendered.length });
5307
5382
  return {
@@ -10024,15 +10099,28 @@ async function performNavigation(pathname, handlers) {
10024
10099
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
10025
10100
  * enabled and supported (instant swap otherwise — never throws).
10026
10101
  *
10102
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
10103
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
10104
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
10105
+ * the destination scroll at this moment means the old and new snapshots share the
10106
+ * same scrollY, so there is no scroll delta for the transition to animate and a
10107
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
10108
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
10109
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
10110
+ *
10027
10111
  * @param doSwap - The synchronous DOM mutation to perform.
10028
10112
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10113
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10114
+ * (e.g. scroll to the destination position).
10029
10115
  * @example
10030
- * runSwap(() => current.replaceWith(next), true);
10116
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
10031
10117
  */
10032
- function runSwap(doSwap, viewTransitions) {
10118
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
10033
10119
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
10034
10120
  const docWithVt = document;
10035
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
10121
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
10122
+ beforeCapture?.();
10123
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
10036
10124
  else doSwap();
10037
10125
  }
10038
10126
  /**
@@ -10045,17 +10133,19 @@ function runSwap(doSwap, viewTransitions) {
10045
10133
  * @param swapSelector - CSS selector for the region to replace.
10046
10134
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10047
10135
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10136
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10137
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10048
10138
  * @example
10049
10139
  * swapRegion(doc, "main > section", false, () => mountNew());
10050
10140
  */
10051
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
10141
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10052
10142
  const newContent = doc.querySelector(swapSelector);
10053
10143
  const currentContent = document.querySelector(swapSelector);
10054
10144
  if (!newContent || !currentContent) return;
10055
10145
  runSwap(() => {
10056
10146
  currentContent.replaceWith(newContent);
10057
10147
  onSwapped();
10058
- }, viewTransitions);
10148
+ }, viewTransitions, beforeCapture);
10059
10149
  }
10060
10150
  /**
10061
10151
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10110,7 +10200,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10110
10200
  }
10111
10201
  saveScrollPosition(location.pathname);
10112
10202
  history.pushState({ scrollY: 0 }, "", url.pathname);
10113
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
10203
+ navigate(url.pathname).catch(() => {});
10114
10204
  };
10115
10205
  /**
10116
10206
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10119,7 +10209,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10119
10209
  * globalThis.addEventListener("popstate", onPopState);
10120
10210
  */
10121
10211
  const onPopState = () => {
10122
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10212
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10123
10213
  };
10124
10214
  document.addEventListener("click", onClick);
10125
10215
  globalThis.addEventListener("popstate", onPopState);
@@ -10163,9 +10253,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10163
10253
  navEvent.intercept({
10164
10254
  scroll: "manual",
10165
10255
  handler: async () => {
10166
- await navigate(url.pathname);
10167
- if (navEvent.navigationType === "traverse") navEvent.scroll();
10168
- else window.scrollTo(0, 0);
10256
+ if (navEvent.navigationType === "traverse") {
10257
+ await navigate(url.pathname, false);
10258
+ navEvent.scroll();
10259
+ } else await navigate(url.pathname);
10169
10260
  }
10170
10261
  });
10171
10262
  };
@@ -10324,6 +10415,25 @@ function syncDataHead(route, routeContext) {
10324
10415
  function createSpaKernel(state, config, emit, deps) {
10325
10416
  const resolved = resolveSpaConfig(config);
10326
10417
  let progress;
10418
+ let pendingScrollToTop = true;
10419
+ /**
10420
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
10421
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
10422
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
10423
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
10424
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
10425
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
10426
+ * saved position after the swap instead.
10427
+ *
10428
+ * @example
10429
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10430
+ */
10431
+ const applyPendingScroll = () => {
10432
+ if (pendingScrollToTop) window.scrollTo({
10433
+ top: 0,
10434
+ behavior: "instant"
10435
+ });
10436
+ };
10327
10437
  /**
10328
10438
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10329
10439
  *
@@ -10339,7 +10449,7 @@ function createSpaKernel(state, config, emit, deps) {
10339
10449
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10340
10450
  scanAndMount(state, emit, resolved.swapSelector);
10341
10451
  notifyNavEnd(state);
10342
- });
10452
+ }, applyPendingScroll);
10343
10453
  state.currentUrl = pathname;
10344
10454
  progress?.done();
10345
10455
  emit("spa:navigated", { url: pathname });
@@ -10434,7 +10544,7 @@ function createSpaKernel(state, config, emit, deps) {
10434
10544
  *
10435
10545
  * @example
10436
10546
  * ```ts
10437
- * runSwap(renderAndMount, resolved.viewTransitions);
10547
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10438
10548
  * ```
10439
10549
  */
10440
10550
  const renderAndMount = () => {
@@ -10442,7 +10552,7 @@ function createSpaKernel(state, config, emit, deps) {
10442
10552
  scanAndMount(state, emit, resolved.swapSelector);
10443
10553
  notifyNavEnd(state);
10444
10554
  };
10445
- runSwap(renderAndMount, resolved.viewTransitions);
10555
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10446
10556
  state.currentUrl = pathname;
10447
10557
  progress?.done();
10448
10558
  emit("spa:navigated", { url: pathname });
@@ -10479,11 +10589,14 @@ function createSpaKernel(state, config, emit, deps) {
10479
10589
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10480
10590
  *
10481
10591
  * @param pathname - The destination pathname.
10592
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10593
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10482
10594
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10483
10595
  * @example
10484
10596
  * await navigate("/en/world/");
10485
10597
  */
10486
- const navigate = async (pathname) => {
10598
+ const navigate = async (pathname, scrollToTop = true) => {
10599
+ pendingScrollToTop = scrollToTop;
10487
10600
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10488
10601
  await performNavigation(pathname, handlers);
10489
10602
  };
package/package.json CHANGED
@@ -113,5 +113,5 @@
113
113
  "test:cli-e2e": "bun test src/plugins/cli/__tests__/e2e/",
114
114
  "test:coverage": "vitest run --project unit --project integration --coverage"
115
115
  },
116
- "version": "1.5.3"
116
+ "version": "1.6.1"
117
117
  }