@moku-labs/web 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Built on the [@moku-labs/core](https://github.com/moku-labs/core) micro-kernel
15
15
  [![CI](https://github.com/moku-labs/web/actions/workflows/ci.yml/badge.svg)](https://github.com/moku-labs/web/actions/workflows/ci.yml)
16
16
  [![npm](https://img.shields.io/npm/v/@moku-labs/web?logo=npm&color=cb3837&label=npm)](https://www.npmjs.com/package/@moku-labs/web)
17
17
  [![types](https://img.shields.io/badge/types-included-3178c6?logo=typescript&logoColor=white)](#requirements)
18
- [![browser bundle](https://img.shields.io/badge/browser%20entry-~45%20kB%20gzip-2da44e)](#the-browser-entry-is-guaranteed-node-free)
18
+ [![browser bundle](https://img.shields.io/badge/browser%20entry-~50%20kB%20gzip-2da44e)](#the-browser-entry-is-guaranteed-node-free)
19
19
  [![node](https://img.shields.io/badge/node-%3E%3D24-339933?logo=node.js&logoColor=white)](#requirements)
20
20
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
21
21
 
@@ -34,18 +34,21 @@ Built on the [@moku-labs/core](https://github.com/moku-labs/core) micro-kernel
34
34
  ---
35
35
 
36
36
  ```sh
37
- bun add @moku-labs/web
37
+ bun add @moku-labs/web preact preact-render-to-string
38
38
  ```
39
39
 
40
40
  > [!NOTE]
41
- > **Status: `0.x` pre-1.0.** The architecture is stable; the public API is settling but not yet frozen. Pin the version the npm badge above tracks the current release.
41
+ > `preact` (and `preact-render-to-string`, used by the SSG build) are **peer dependencies** — your app compiles its JSX against the same single `preact` instance the framework renders with. Most package managers install peers automatically, but declare them explicitly so *you* own the version: a second nested copy of preact silently breaks hooks and island hydration.
42
+
43
+ > [!NOTE]
44
+ > **Status: `1.x` — stable.** The architecture and public API are stable and follow [semver](https://semver.org) — breaking changes land only in a new major. The npm badge above tracks the current release.
42
45
 
43
46
  ## Why @moku-labs/web
44
47
 
45
48
  - **SSG first, SPA when you want it.** Render [Preact](https://preactjs.com) pages to static HTML for SEO and instant first paint, then progressively enhance with island hydration and client-side navigation — opt in per project with a single switch.
46
49
  - **The route is the contract.** One typed `route()` builder owns `load` → `render` → `head`. The build and the client run the *same* `render`, so there's no second code path to keep in sync. [Jump to the example ↓](#the-route-is-the-contract)
47
50
  - **SEO complete out of the box.** Title templates, canonical + `hreflang`, Open Graph / Twitter cards, JSON-LD, RSS / Atom / JSON feeds, `sitemap.xml`, and generated OG images.
48
- - **The `/browser` entry is guaranteed node-free.** A dedicated client entry whose static import graph references *zero* node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~45 kB gzip today). [Why this matters ↓](#the-browser-entry-is-guaranteed-node-free)
51
+ - **The `/browser` entry is guaranteed node-free.** A dedicated client entry whose static import graph references *zero* node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~50 kB gzip today, 60 kB budget). [Why this matters ↓](#the-browser-entry-is-guaranteed-node-free)
49
52
  - **Plugins all the way down.** A tiny isomorphic core (`site`, `i18n`, `router`, `head`, `spa`) plus opt-in node-only plugins (`content`, `build`, `deploy`, `cli`), each [independently documented](#plugins) and composed in one `createApp` call.
50
53
  - **Types do the heavy lifting.** `ctx.data` is inferred from your `.load()`, path params from the route pattern, plugin APIs from their specs — no codegen, no `as`.
51
54
  - **i18n is built in.** Locale-aware routes, default-locale fallback, `hreflang` / `og:locale` maps.
@@ -236,7 +239,7 @@ bun run build # build with tsdown (dual ESM+CJS + ESM-only browser
236
239
  bun run test # all tests (vitest)
237
240
  bun run test:unit # unit tests only
238
241
  bun run test:integration # integration tests only
239
- bun run test:coverage # tests with coverage (90% threshold)
242
+ bun run test:coverage # tests with coverage (85% threshold)
240
243
  bun run lint # biome check + eslint
241
244
  bun run lint:fix # auto-fix lint issues
242
245
  bun run format # format with biome
@@ -246,7 +249,7 @@ bun run check:bundle # assert the browser bundle is node-free + under the
246
249
 
247
250
  ## Requirements
248
251
 
249
- - **Node `>= 24`** — the router uses the global [`URLPattern`](https://developer.mozilla.org/docs/Web/API/URLPattern).
252
+ - **Node `>= 24`** — the engines floor declared in `package.json`. (The route matcher is native `RegExp` — no [`URLPattern`](https://developer.mozilla.org/docs/Web/API/URLPattern) global needed, in Node or in any browser.)
250
253
  - **Bun `>= 1.3.14`** — the package manager and test runner. Use `bun` exclusively (never npm/yarn/pnpm).
251
254
  - **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
252
255
 
package/dist/browser.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
- import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
2
+ import { n as relativeDataFile, t as dataSuffix } from "./convention-Dp650o3y.mjs";
3
3
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
4
4
  //#region src/plugins/env/api.ts
5
5
  /** Error prefix for all env API failures. */
@@ -1313,21 +1313,42 @@ function bySpecificity(a, b) {
1313
1313
  return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1314
1314
  }
1315
1315
  /**
1316
+ * Decode a captured group's percent-escapes so params round-trip with
1317
+ * `buildUrl`'s encoding (matchers run against the encoded `location.pathname`).
1318
+ * Falls back to the raw text on malformed escapes (never throw mid-match).
1319
+ *
1320
+ * @param value - The raw captured segment text (possibly percent-encoded).
1321
+ * @returns The decoded param value, or the raw text on malformed escapes.
1322
+ * @example
1323
+ * ```ts
1324
+ * decodeGroupValue("c%23%20tips"); // "c# tips"
1325
+ * ```
1326
+ */
1327
+ function decodeGroupValue(value) {
1328
+ try {
1329
+ return decodeURIComponent(value);
1330
+ } catch {
1331
+ return value;
1332
+ }
1333
+ }
1334
+ /**
1316
1335
  * Extract named groups from a `URLPattern` match result, dropping numeric/regex
1317
1336
  * group keys and `undefined` values so only declared, present params remain.
1337
+ * Each value is percent-DECODED ({@link decodeGroupValue}) back to the literal
1338
+ * value `buildUrl` was given.
1318
1339
  *
1319
1340
  * @param groups - The `URLPatternResult.pathname.groups` object.
1320
1341
  * @returns A clean record of named params.
1321
1342
  * @example
1322
1343
  * ```ts
1323
- * extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
1344
+ * extractGroups({ slug: "hello%20there", "0": "x" }); // { slug: "hello there" }
1324
1345
  * ```
1325
1346
  */
1326
1347
  function extractGroups(groups) {
1327
1348
  const params = {};
1328
1349
  for (const [key, value] of Object.entries(groups)) {
1329
1350
  if (/^\d+$/.test(key)) continue;
1330
- if (value !== void 0) params[key] = value;
1351
+ if (value !== void 0) params[key] = decodeGroupValue(value);
1331
1352
  }
1332
1353
  return params;
1333
1354
  }
@@ -1582,27 +1603,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
1582
1603
  return out.join("/");
1583
1604
  }
1584
1605
  /**
1585
- * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
1586
- * Walks segment-by-segment (no backtracking regex). An optional placeholder whose
1587
- * param is absent has its segment skipped entirely (no empty segment), so a missing
1588
- * `{lang:?}` collapses cleanly instead of leaving a double slash.
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).
1606
+ * Substitute a pattern's placeholders one `/`-segment at a time (no backtracking
1607
+ * regex), passing each value through `encodeValue` percent-encoding for
1608
+ * {@link buildUrl}, identity for {@link buildFilePath}. An absent optional segment
1609
+ * collapses (no double slash), as does `{lang:?}` for the bare `defaultLocale`.
1593
1610
  *
1594
1611
  * @param pattern - The route pattern.
1595
1612
  * @param params - Param values to substitute.
1596
1613
  * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
1597
- * @returns The resolved relative URL string.
1614
+ * @param encodeValue - Encoder applied to each substituted param value.
1615
+ * @returns The resolved relative path string.
1598
1616
  * @example
1599
1617
  * ```ts
1600
- * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
1601
- * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
1602
- * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
1618
+ * substitutePattern("/{slug}/", { slug: "a b" }, undefined, encodeURIComponent); // "/a%20b/"
1603
1619
  * ```
1604
1620
  */
1605
- function buildUrl(pattern, params, defaultLocale) {
1621
+ function substitutePattern(pattern, params, defaultLocale, encodeValue) {
1606
1622
  const out = [];
1607
1623
  for (const segment of pattern.split("/")) {
1608
1624
  const placeholder = parsePlaceholder(segment);
@@ -1613,24 +1629,48 @@ function buildUrl(pattern, params, defaultLocale) {
1613
1629
  const value = params[placeholder.name] ?? "";
1614
1630
  if (placeholder.optional && value === "") continue;
1615
1631
  if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
1616
- out.push(value);
1632
+ out.push(encodeValue(value));
1617
1633
  }
1618
1634
  return out.join("/");
1619
1635
  }
1620
1636
  /**
1637
+ * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`;
1638
+ * segment walk in {@link substitutePattern}). Substituted values are
1639
+ * percent-encoded so reserved characters (`#`, `?`, `&`, spaces, …) cannot
1640
+ * truncate the path or break the sitemap XML, and the URL round-trips through
1641
+ * the matchers (`extractGroups` decodes captures back).
1642
+ *
1643
+ * @param pattern - The route pattern.
1644
+ * @param params - Param values to substitute.
1645
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
1646
+ * @returns The resolved relative URL string.
1647
+ * @example
1648
+ * ```ts
1649
+ * buildUrl("/{slug}/", { slug: "a & b" }); // "/a%20%26%20b/"
1650
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
1651
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
1652
+ * ```
1653
+ */
1654
+ function buildUrl(pattern, params, defaultLocale) {
1655
+ return substitutePattern(pattern, params, defaultLocale, encodeURIComponent);
1656
+ }
1657
+ /**
1621
1658
  * Build an output file path from a pattern and params (always `…/index.html`).
1659
+ * Param values stay LITERAL: servers decode the encoded request path before
1660
+ * filesystem lookup, so on-disk names carry the decoded text.
1622
1661
  *
1623
1662
  * @param pattern - The route pattern.
1624
1663
  * @param params - Param values to substitute.
1625
- * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
1664
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
1626
1665
  * @returns The output file path, e.g. `hello/index.html`.
1627
1666
  * @example
1628
1667
  * ```ts
1629
- * buildFilePath("/{slug}/", { slug: "hello" });
1668
+ * buildFilePath("/{slug}/", { slug: "hello" }); // "hello/index.html"
1669
+ * buildFilePath("/{tag}/", { tag: "a & b" }); // "a & b/index.html"
1630
1670
  * ```
1631
1671
  */
1632
1672
  function buildFilePath(pattern, params, defaultLocale) {
1633
- const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
1673
+ const cleanPath = substitutePattern(pattern, params, defaultLocale, (value) => value).replace(/^\//, "").replace(/\/$/, "");
1634
1674
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
1635
1675
  }
1636
1676
  /**
@@ -2433,8 +2473,11 @@ function resolveImage(image, site) {
2433
2473
  }
2434
2474
  /**
2435
2475
  * Build the per-locale `hreflang` alternates for a route, plus the `x-default`
2436
- * fallback (the route's URL with no `lang` override). Each alternate URL is the
2437
- * route's canonical URL for that locale, absolutized against the site base URL.
2476
+ * fallback (the route's URL with `lang` STRIPPED, i.e. the bare default-locale
2477
+ * URL). Each alternate URL is the route's canonical URL for that locale,
2478
+ * absolutized against the site base URL. Stripping `lang` — rather than keeping
2479
+ * the page's own locale — keeps the x-default href byte-identical across every
2480
+ * locale variant of the route, as the hreflang spec requires.
2438
2481
  *
2439
2482
  * @param locales - The supported locale codes (drives the alternate set).
2440
2483
  * @param route - The resolved route descriptor (provides `name` + `params`).
@@ -2450,7 +2493,9 @@ function buildHreflangAlternates(locales, route, router, site) {
2450
2493
  lang: locale
2451
2494
  })));
2452
2495
  });
2453
- const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
2496
+ const bareParams = { ...route.params };
2497
+ delete bareParams.lang;
2498
+ const xDefaultHref = site.canonical(router.toUrl(route.name, bareParams));
2454
2499
  alternates.push(hreflang(X_DEFAULT, xDefaultHref));
2455
2500
  return alternates;
2456
2501
  }
@@ -3021,7 +3066,7 @@ function dataApi(ctx) {
3021
3066
  * ```
3022
3067
  */
3023
3068
  async write(entries, options) {
3024
- const { writeData } = await import("./writer-Dc_lx22j.mjs");
3069
+ const { writeData } = await import("./writer-CaoyORyZ.mjs");
3025
3070
  return writeData(ctx, entries, options);
3026
3071
  },
3027
3072
  /**
@@ -3587,6 +3632,23 @@ function isInternalLink(url) {
3587
3632
  return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
3588
3633
  }
3589
3634
  /**
3635
+ * The navigable path of a URL or Location: pathname plus query string. The query
3636
+ * is part of page identity (the kernel's `currentUrl` is pathname + search), so
3637
+ * same-page checks, history entries, fetches, and scroll keys must all carry it —
3638
+ * comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
3639
+ * and the History fallback would drop the query from the address bar.
3640
+ *
3641
+ * @param target - The URL or Location to read.
3642
+ * @param target.pathname - The path component.
3643
+ * @param target.search - The query-string component (`""` when absent).
3644
+ * @returns The pathname + search string.
3645
+ * @example
3646
+ * pathWithSearch(new URL("https://x.dev/search?q=a")); // "/search?q=a"
3647
+ */
3648
+ function pathWithSearch(target) {
3649
+ return target.pathname + target.search;
3650
+ }
3651
+ /**
3590
3652
  * Save the current scroll position keyed by path (best-effort; ignores storage errors).
3591
3653
  *
3592
3654
  * @param path - The path to key the scroll position under.
@@ -3615,19 +3677,27 @@ function restoreScrollPosition(path) {
3615
3677
  * Fetch a page and hand its HTML to the handlers; on any error fall back to a
3616
3678
  * full browser navigation (`location.href = pathname`).
3617
3679
  *
3680
+ * When `signal` aborts (this navigation was superseded by a newer one) the
3681
+ * fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
3682
+ * reload — the live navigation owns the document from that point on.
3683
+ *
3618
3684
  * @param pathname - The destination pathname.
3619
3685
  * @param handlers - The navigation lifecycle callbacks.
3686
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
3620
3687
  * @returns A promise that resolves once the swap (or fallback) is dispatched.
3621
3688
  * @example
3622
- * await performNavigation("/about", handlers);
3689
+ * await performNavigation("/about", handlers, navEvent.signal);
3623
3690
  */
3624
- async function performNavigation(pathname, handlers) {
3691
+ async function performNavigation(pathname, handlers, signal) {
3625
3692
  handlers.onStart(pathname);
3626
3693
  try {
3627
- const response = await fetch(pathname);
3694
+ const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
3628
3695
  if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
3629
- handlers.onEnd(await response.text(), pathname);
3696
+ const html = await response.text();
3697
+ if (signal?.aborted) return;
3698
+ handlers.onEnd(html, pathname);
3630
3699
  } catch {
3700
+ if (signal?.aborted) return;
3631
3701
  handlers.onError();
3632
3702
  location.href = pathname;
3633
3703
  }
@@ -3666,23 +3736,29 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
3666
3736
  * inside the same transition frame (after the DOM mutation) so component
3667
3737
  * re-mounting is captured by the transition snapshot.
3668
3738
  *
3739
+ * Returns whether the swap was dispatched: `false` when either document lacks
3740
+ * the `swapSelector` region, so the caller can fall back to a full navigation
3741
+ * instead of finishing the SPA nav against an un-swapped body.
3742
+ *
3669
3743
  * @param doc - The fetched document (DOMParser-parsed) holding the new region.
3670
3744
  * @param swapSelector - CSS selector for the region to replace.
3671
3745
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
3672
3746
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
3673
3747
  * @param beforeCapture - Optional hook run synchronously just before the swap/capture
3674
3748
  * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
3749
+ * @returns `true` when the swap was dispatched, `false` when either document lacks the region.
3675
3750
  * @example
3676
3751
  * swapRegion(doc, "main > section", false, () => mountNew());
3677
3752
  */
3678
3753
  function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
3679
3754
  const newContent = doc.querySelector(swapSelector);
3680
3755
  const currentContent = document.querySelector(swapSelector);
3681
- if (!newContent || !currentContent) return;
3756
+ if (!newContent || !currentContent) return false;
3682
3757
  runSwap(() => {
3683
3758
  currentContent.replaceWith(newContent);
3684
3759
  onSwapped();
3685
3760
  }, viewTransitions, beforeCapture);
3761
+ return true;
3686
3762
  }
3687
3763
  /**
3688
3764
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -3716,7 +3792,20 @@ function resolveClickTarget(event) {
3716
3792
  * @example
3717
3793
  * const dispose = attachHistoryFallback(handlers);
3718
3794
  */
3719
- function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
3795
+ function attachHistoryFallback(handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
3796
+ let controller;
3797
+ /**
3798
+ * Supersede the in-flight navigation (if any) and mint the next one's abort signal.
3799
+ *
3800
+ * @returns The fresh navigation's abort signal.
3801
+ * @example
3802
+ * const signal = supersede();
3803
+ */
3804
+ const supersede = () => {
3805
+ controller?.abort();
3806
+ controller = new AbortController();
3807
+ return controller.signal;
3808
+ };
3720
3809
  /**
3721
3810
  * Intercept an internal-link click and run a History-API navigation.
3722
3811
  *
@@ -3727,17 +3816,18 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3727
3816
  const onClick = (event) => {
3728
3817
  const url = resolveClickTarget(event);
3729
3818
  if (!url) return;
3819
+ if (url.pathname === location.pathname && url.hash) return;
3730
3820
  event.preventDefault();
3731
- if (url.pathname === location.pathname) {
3821
+ if (pathWithSearch(url) === pathWithSearch(location)) {
3732
3822
  window.scrollTo({
3733
3823
  top: 0,
3734
3824
  behavior: "smooth"
3735
3825
  });
3736
3826
  return;
3737
3827
  }
3738
- saveScrollPosition(location.pathname);
3739
- history.pushState({ scrollY: 0 }, "", url.pathname);
3740
- navigate(url.pathname).catch(() => {});
3828
+ saveScrollPosition(pathWithSearch(location));
3829
+ history.pushState({ scrollY: 0 }, "", pathWithSearch(url));
3830
+ navigate(pathWithSearch(url), true, supersede()).catch(() => {});
3741
3831
  };
3742
3832
  /**
3743
3833
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -3746,7 +3836,11 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3746
3836
  * globalThis.addEventListener("popstate", onPopState);
3747
3837
  */
3748
3838
  const onPopState = () => {
3749
- navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
3839
+ const path = pathWithSearch(location);
3840
+ const signal = supersede();
3841
+ navigate(path, false, signal).then(() => {
3842
+ if (!signal.aborted) restoreScrollPosition(path);
3843
+ }).catch(() => {});
3750
3844
  };
3751
3845
  document.addEventListener("click", onClick);
3752
3846
  globalThis.addEventListener("popstate", onPopState);
@@ -3765,7 +3859,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3765
3859
  * @example
3766
3860
  * const dispose = attachNavigationApi(navigation, handlers);
3767
3861
  */
3768
- function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
3862
+ function attachNavigationApi(navigation, handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
3769
3863
  /**
3770
3864
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
3771
3865
  *
@@ -3777,7 +3871,7 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
3777
3871
  const url = new URL(navEvent.destination.url);
3778
3872
  if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
3779
3873
  if (!isInternalLink(url)) return;
3780
- if (url.pathname === location.pathname) {
3874
+ if (pathWithSearch(url) === pathWithSearch(location)) {
3781
3875
  navEvent.intercept({ handler: () => {
3782
3876
  window.scrollTo({
3783
3877
  top: 0,
@@ -3791,9 +3885,9 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
3791
3885
  scroll: "manual",
3792
3886
  handler: async () => {
3793
3887
  if (navEvent.navigationType === "traverse") {
3794
- await navigate(url.pathname, false);
3795
- navEvent.scroll();
3796
- } else await navigate(url.pathname);
3888
+ await navigate(pathWithSearch(url), false, navEvent.signal);
3889
+ if (!navEvent.signal.aborted) navEvent.scroll();
3890
+ } else await navigate(pathWithSearch(url), true, navEvent.signal);
3797
3891
  }
3798
3892
  });
3799
3893
  };
@@ -3979,6 +4073,11 @@ function createSpaKernel(state, config, emit, deps) {
3979
4073
  };
3980
4074
  /**
3981
4075
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
4076
+ * When the region cannot be swapped (either document lacks the swap selector)
4077
+ * the SPA nav cannot complete — the head is already synced and the islands torn
4078
+ * down, so finishing would leave the OLD body under a NEW URL with a `spa:navigated`
4079
+ * claiming success. Fall back to a full browser navigation instead (mirroring
4080
+ * {@link performNavigation}'s fetch-error fallback).
3982
4081
  *
3983
4082
  * @param html - The fetched page HTML.
3984
4083
  * @param pathname - The destination pathname.
@@ -3989,10 +4088,14 @@ function createSpaKernel(state, config, emit, deps) {
3989
4088
  const doc = new DOMParser().parseFromString(html, "text/html");
3990
4089
  syncHead(deps.head, doc);
3991
4090
  unmountPageSpecific(state, emit);
3992
- swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
4091
+ if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3993
4092
  scanAndMount(state, emit, resolved.swapSelector);
3994
4093
  notifyNavEnd(state);
3995
- }, applyPendingScroll);
4094
+ }, applyPendingScroll)) {
4095
+ handleError();
4096
+ location.href = pathname;
4097
+ return;
4098
+ }
3996
4099
  state.currentUrl = pathname;
3997
4100
  progress?.done();
3998
4101
  emit("spa:navigated", { url: pathname });
@@ -4072,13 +4175,16 @@ function createSpaKernel(state, config, emit, deps) {
4072
4175
  *
4073
4176
  * @param pathname - The destination pathname (recorded as the new current URL).
4074
4177
  * @param resolvedRender - The inputs produced by {@link resolveDataRender}.
4178
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
4075
4179
  * @example
4076
4180
  * await commitDataRender("/en/world/", resolved);
4077
4181
  */
4078
- const commitDataRender = async (pathname, resolvedRender) => {
4182
+ const commitDataRender = async (pathname, resolvedRender, signal) => {
4183
+ if (signal?.aborted) return;
4079
4184
  const { route, vnode, routeContext, region } = resolvedRender;
4080
4185
  handleStart(pathname);
4081
4186
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
4187
+ if (signal?.aborted) return;
4082
4188
  syncDataHead(route, routeContext);
4083
4189
  unmountPageSpecific(state, emit);
4084
4190
  /**
@@ -4110,15 +4216,16 @@ function createSpaKernel(state, config, emit, deps) {
4110
4216
  * to HTML-over-fetch.
4111
4217
  *
4112
4218
  * @param pathname - The destination pathname (search stripped for matching).
4219
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
4113
4220
  * @returns `true` if the route was rendered from its data, else `false`.
4114
4221
  * @example
4115
4222
  * if (await tryDataRender("/en/world/")) return;
4116
4223
  */
4117
- const tryDataRender = async (pathname) => {
4224
+ const tryDataRender = async (pathname, signal) => {
4118
4225
  try {
4119
4226
  const resolvedRender = await resolveDataRender(pathname);
4120
4227
  if (resolvedRender === false) return false;
4121
- await commitDataRender(pathname, resolvedRender);
4228
+ await commitDataRender(pathname, resolvedRender, signal);
4122
4229
  return true;
4123
4230
  } catch {
4124
4231
  progress?.done();
@@ -4134,14 +4241,17 @@ function createSpaKernel(state, config, emit, deps) {
4134
4241
  * @param pathname - The destination pathname.
4135
4242
  * @param scrollToTop - Whether the swap should scroll to top before its snapshot
4136
4243
  * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
4244
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
4245
+ * a superseded navigation never applies its swap (no stale last-write-wins).
4137
4246
  * @returns A promise resolving once the swap (or fallback) is dispatched.
4138
4247
  * @example
4139
4248
  * await navigate("/en/world/");
4140
4249
  */
4141
- const navigate = async (pathname, scrollToTop = true) => {
4250
+ const navigate = async (pathname, scrollToTop = true, signal) => {
4142
4251
  pendingScrollToTop = scrollToTop;
4143
- if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
4144
- await performNavigation(pathname, handlers);
4252
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
4253
+ if (signal?.aborted) return;
4254
+ await performNavigation(pathname, handlers, signal);
4145
4255
  };
4146
4256
  return {
4147
4257
  /**
@@ -4675,6 +4785,15 @@ function createContentApi(ctx) {
4675
4785
  * suppressed and throws the SAME not-found error (drafts indistinguishable from
4676
4786
  * missing); in development and test drafts load normally.
4677
4787
  *
4788
+ * Cache-first: when a preceding `loadAll()` (or earlier `load()`) already resolved +
4789
+ * rendered this `(slug, locale)`, the cached Article (full html included) is returned
4790
+ * without re-running the Markdown/Shiki pipeline — during a full build every
4791
+ * per-article route loader would otherwise re-render an article `loadAll()` just
4792
+ * rendered. Draft semantics are preserved: in production `loadAll()` filters drafts
4793
+ * out BEFORE caching and the production `load()` path throws before caching, so a
4794
+ * production cache hit is never a draft; misses fall through to a fresh resolve,
4795
+ * which suppresses drafts exactly as before.
4796
+ *
4678
4797
  * @param slug - Article directory name.
4679
4798
  * @param locale - Requested locale code.
4680
4799
  * @returns The resolved Article.
@@ -4686,6 +4805,8 @@ function createContentApi(ctx) {
4686
4805
  * ```
4687
4806
  */
4688
4807
  async load(slug, locale) {
4808
+ const cached = ctx.state.articles.get(locale)?.get(slug);
4809
+ if (cached !== void 0) return cached;
4689
4810
  const article = await resolveArticle(ctx, slug, locale);
4690
4811
  if (article === null) throw articleNotFound(slug, locale);
4691
4812
  if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
@@ -36,10 +36,14 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  * ONE function maps a page path to its data suffix, so the browser fetch URL
37
37
  * (`baseUrl + suffix`) and the on-disk file (`outputDir + "/" + suffix`) are
38
38
  * derived from the same source and cannot drift. The data file mirrors the page
39
- * URL exactly, mirroring how `build` writes `…/index.html` per page:
39
+ * URL, mirroring how `build` writes `…/index.html` per page:
40
40
  * `/` → `index.json`
41
41
  * `/en/hello/` → `en/hello/index.json`
42
42
  * `/en/hello` → `en/hello/index.json` (trailing slash normalized)
43
+ *
44
+ * Encoding split: the FETCH suffix ({@link dataSuffix}) keeps the page URL's
45
+ * percent-encoding (the browser requests the encoded path); the FILE path
46
+ * ({@link relativeDataFile}) decodes it, like the page's own `…/index.html`.
43
47
  */
44
48
  /**
45
49
  * Compute the data-file suffix for a page path: strip the leading slash, ensure a
@@ -61,9 +65,29 @@ function dataSuffix(path) {
61
65
  return trimmed.length > 0 ? `${trimmed}/index.json` : "index.json";
62
66
  }
63
67
  /**
68
+ * Decode a data suffix's percent-escapes so the on-disk file carries the
69
+ * literal name (servers decode the encoded fetch URL before filesystem
70
+ * lookup). Falls back to the raw suffix on malformed escapes.
71
+ *
72
+ * @param suffix - The computed data suffix (possibly percent-encoded).
73
+ * @returns The decoded suffix, or the raw suffix on malformed escapes.
74
+ * @example
75
+ * ```ts
76
+ * decodeSuffix("en/tags/a%20%26%20b/index.json"); // "en/tags/a & b/index.json"
77
+ * ```
78
+ */
79
+ function decodeSuffix(suffix) {
80
+ try {
81
+ return decodeURIComponent(suffix);
82
+ } catch {
83
+ return suffix;
84
+ }
85
+ }
86
+ /**
64
87
  * Compute the `outputDir`-relative data file for a page path, joining the trimmed
65
- * output dir with {@link dataSuffix}. Shared by the Node writer and the pure
66
- * `fileFor` accessor so the written file and the reported path can never drift.
88
+ * output dir with the DECODED {@link dataSuffix} (servers resolve the decoded
89
+ * request path against literal file names). Shared by the Node writer and the
90
+ * pure `fileFor` accessor so the written file and the reported path never drift.
67
91
  *
68
92
  * @param outputDir - The configured data output subdir (e.g. `"_data"` or `"_data/"`).
69
93
  * @param path - The page URL path (e.g. `/en/hello/`).
@@ -75,7 +99,7 @@ function dataSuffix(path) {
75
99
  * ```
76
100
  */
77
101
  function relativeDataFile(outputDir, path) {
78
- return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${dataSuffix(path)}`;
102
+ return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${decodeSuffix(dataSuffix(path))}`;
79
103
  }
80
104
  //#endregion
81
105
  Object.defineProperty(exports, "__exportAll", {
@@ -5,10 +5,14 @@
5
5
  * ONE function maps a page path to its data suffix, so the browser fetch URL
6
6
  * (`baseUrl + suffix`) and the on-disk file (`outputDir + "/" + suffix`) are
7
7
  * derived from the same source and cannot drift. The data file mirrors the page
8
- * URL exactly, mirroring how `build` writes `…/index.html` per page:
8
+ * URL, mirroring how `build` writes `…/index.html` per page:
9
9
  * `/` → `index.json`
10
10
  * `/en/hello/` → `en/hello/index.json`
11
11
  * `/en/hello` → `en/hello/index.json` (trailing slash normalized)
12
+ *
13
+ * Encoding split: the FETCH suffix ({@link dataSuffix}) keeps the page URL's
14
+ * percent-encoding (the browser requests the encoded path); the FILE path
15
+ * ({@link relativeDataFile}) decodes it, like the page's own `…/index.html`.
12
16
  */
13
17
  /**
14
18
  * Compute the data-file suffix for a page path: strip the leading slash, ensure a
@@ -30,9 +34,29 @@ function dataSuffix(path) {
30
34
  return trimmed.length > 0 ? `${trimmed}/index.json` : "index.json";
31
35
  }
32
36
  /**
37
+ * Decode a data suffix's percent-escapes so the on-disk file carries the
38
+ * literal name (servers decode the encoded fetch URL before filesystem
39
+ * lookup). Falls back to the raw suffix on malformed escapes.
40
+ *
41
+ * @param suffix - The computed data suffix (possibly percent-encoded).
42
+ * @returns The decoded suffix, or the raw suffix on malformed escapes.
43
+ * @example
44
+ * ```ts
45
+ * decodeSuffix("en/tags/a%20%26%20b/index.json"); // "en/tags/a & b/index.json"
46
+ * ```
47
+ */
48
+ function decodeSuffix(suffix) {
49
+ try {
50
+ return decodeURIComponent(suffix);
51
+ } catch {
52
+ return suffix;
53
+ }
54
+ }
55
+ /**
33
56
  * Compute the `outputDir`-relative data file for a page path, joining the trimmed
34
- * output dir with {@link dataSuffix}. Shared by the Node writer and the pure
35
- * `fileFor` accessor so the written file and the reported path can never drift.
57
+ * output dir with the DECODED {@link dataSuffix} (servers resolve the decoded
58
+ * request path against literal file names). Shared by the Node writer and the
59
+ * pure `fileFor` accessor so the written file and the reported path never drift.
36
60
  *
37
61
  * @param outputDir - The configured data output subdir (e.g. `"_data"` or `"_data/"`).
38
62
  * @param path - The page URL path (e.g. `/en/hello/`).
@@ -44,7 +68,7 @@ function dataSuffix(path) {
44
68
  * ```
45
69
  */
46
70
  function relativeDataFile(outputDir, path) {
47
- return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${dataSuffix(path)}`;
71
+ return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${decodeSuffix(dataSuffix(path))}`;
48
72
  }
49
73
  //#endregion
50
74
  export { relativeDataFile as n, dataSuffix as t };