@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 +9 -6
- package/dist/browser.mjs +169 -48
- package/dist/{convention-krwh7Y6Q.cjs → convention-BpDfzX7e.cjs} +28 -4
- package/dist/{convention-CepUwWmT.mjs → convention-Dp650o3y.mjs} +28 -4
- package/dist/index.cjs +365 -115
- package/dist/index.mjs +365 -115
- package/dist/{writer-Dc_lx22j.mjs → writer-CaoyORyZ.mjs} +1 -1
- package/dist/{writer-DV5hWB2i.cjs → writer-JdhX1Wld.cjs} +1 -1
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Built on the [@moku-labs/core](https://github.com/moku-labs/core) micro-kernel
|
|
|
15
15
|
[](https://github.com/moku-labs/web/actions/workflows/ci.yml)
|
|
16
16
|
[](https://www.npmjs.com/package/@moku-labs/web)
|
|
17
17
|
[](#requirements)
|
|
18
|
-
[](#the-browser-entry-is-guaranteed-node-free)
|
|
19
19
|
[](#requirements)
|
|
20
20
|
[](./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
|
-
>
|
|
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 (~
|
|
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 (
|
|
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
|
|
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-
|
|
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
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* `{lang:?}`
|
|
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
|
-
* @
|
|
1614
|
+
* @param encodeValue - Encoder applied to each substituted param value.
|
|
1615
|
+
* @returns The resolved relative path string.
|
|
1598
1616
|
* @example
|
|
1599
1617
|
* ```ts
|
|
1600
|
-
*
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
2437
|
-
* route's canonical URL for that locale,
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
3739
|
-
history.pushState({ scrollY: 0 }, "", url
|
|
3740
|
-
navigate(url
|
|
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
|
-
|
|
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
|
|
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
|
|
3795
|
-
navEvent.scroll();
|
|
3796
|
-
} else await navigate(url.
|
|
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
|
-
|
|
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
|
|
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}
|
|
66
|
-
*
|
|
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
|
|
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}
|
|
35
|
-
*
|
|
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 };
|