@moku-labs/web 1.6.1 → 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/dist/index.cjs CHANGED
@@ -1,9 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_convention = require("./convention-krwh7Y6Q.cjs");
2
+ const require_convention = require("./convention-BpDfzX7e.cjs");
3
3
  let _moku_labs_core = require("@moku-labs/core");
4
4
  let node_fs = require("node:fs");
5
5
  let node_crypto = require("node:crypto");
6
6
  let node_fs_promises = require("node:fs/promises");
7
+ let node_os = require("node:os");
7
8
  let node_path = require("node:path");
8
9
  let node_path$1 = require_convention.__toESM(node_path, 1);
9
10
  node_path = require_convention.__toESM(node_path);
@@ -13,7 +14,6 @@ let preact_render_to_string = require("preact-render-to-string");
13
14
  let node_child_process = require("node:child_process");
14
15
  let node_readline = require("node:readline");
15
16
  let node_url = require("node:url");
16
- let node_os = require("node:os");
17
17
  let gray_matter = require("gray-matter");
18
18
  gray_matter = require_convention.__toESM(gray_matter, 1);
19
19
  let _shikijs_rehype = require("@shikijs/rehype");
@@ -41,7 +41,7 @@ let reading_time = require("reading-time");
41
41
  reading_time = require_convention.__toESM(reading_time, 1);
42
42
  //#region src/plugins/env/api.ts
43
43
  /** Error prefix for all env API failures. */
44
- const ERROR_PREFIX$15 = "[web]";
44
+ const ERROR_PREFIX$16 = "[web]";
45
45
  /**
46
46
  * Creates the env plugin API surface mounted at `ctx.env`. Closes over
47
47
  * `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
@@ -85,7 +85,7 @@ function createEnvApi(ctx) {
85
85
  */
86
86
  require(key) {
87
87
  const value = resolved.get(key);
88
- if (value === void 0) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined.`);
88
+ if (value === void 0) throw new Error(`${ERROR_PREFIX$16} env: required variable "${key}" is not defined.`);
89
89
  return value;
90
90
  },
91
91
  /**
@@ -152,7 +152,7 @@ function createEnvState() {
152
152
  /** Error message thrown by every frozen-map mutator. */
153
153
  const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
154
154
  /** Error prefix for all resolution-pipeline failures. */
155
- const ERROR_PREFIX$14 = "[web]";
155
+ const ERROR_PREFIX$15 = "[web]";
156
156
  /** The `Map` mutators redefined as throwers when a map is frozen. */
157
157
  const FROZEN_METHODS = [
158
158
  "set",
@@ -221,8 +221,8 @@ function crossCheckPublicPrefix(config) {
221
221
  const { schema, publicPrefix } = config;
222
222
  for (const [key, spec] of Object.entries(schema)) {
223
223
  const hasPrefix = key.startsWith(publicPrefix);
224
- if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$14} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
225
- if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$14} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
224
+ if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$15} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
225
+ if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$15} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
226
226
  }
227
227
  }
228
228
  /**
@@ -304,7 +304,7 @@ function validateSchema(ctx) {
304
304
  crossCheckPublicPrefix(config);
305
305
  for (const [key, spec] of Object.entries(schema)) {
306
306
  if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
307
- if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$14} env: required variable "${key}" is not defined by any provider or default.`);
307
+ if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined by any provider or default.`);
308
308
  }
309
309
  populatePublicMap(schema, merged, state.publicMap);
310
310
  populateResolved(merged, state.resolved);
@@ -912,7 +912,7 @@ const createCore = coreConfig.createCore;
912
912
  //#endregion
913
913
  //#region src/plugins/i18n/api.ts
914
914
  /** Error prefix for all i18n lifecycle failures. */
915
- const ERROR_PREFIX$13 = "[web]";
915
+ const ERROR_PREFIX$14 = "[web]";
916
916
  /**
917
917
  * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
918
918
  * `locales` is empty or when `defaultLocale` is not a member of `locales`.
@@ -928,8 +928,8 @@ const ERROR_PREFIX$13 = "[web]";
928
928
  */
929
929
  function validateI18nConfig(ctx) {
930
930
  const { locales, defaultLocale } = ctx.config;
931
- if (locales.length === 0) throw new Error(`${ERROR_PREFIX$13} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
932
- if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$13} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
931
+ if (locales.length === 0) throw new Error(`${ERROR_PREFIX$14} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
932
+ if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$14} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
933
933
  }
934
934
  /**
935
935
  * Creates the i18n plugin API surface — locale registry accessors plus the
@@ -1419,6 +1419,15 @@ function createContentApi(ctx) {
1419
1419
  * suppressed and throws the SAME not-found error (drafts indistinguishable from
1420
1420
  * missing); in development and test drafts load normally.
1421
1421
  *
1422
+ * Cache-first: when a preceding `loadAll()` (or earlier `load()`) already resolved +
1423
+ * rendered this `(slug, locale)`, the cached Article (full html included) is returned
1424
+ * without re-running the Markdown/Shiki pipeline — during a full build every
1425
+ * per-article route loader would otherwise re-render an article `loadAll()` just
1426
+ * rendered. Draft semantics are preserved: in production `loadAll()` filters drafts
1427
+ * out BEFORE caching and the production `load()` path throws before caching, so a
1428
+ * production cache hit is never a draft; misses fall through to a fresh resolve,
1429
+ * which suppresses drafts exactly as before.
1430
+ *
1422
1431
  * @param slug - Article directory name.
1423
1432
  * @param locale - Requested locale code.
1424
1433
  * @returns The resolved Article.
@@ -1430,6 +1439,8 @@ function createContentApi(ctx) {
1430
1439
  * ```
1431
1440
  */
1432
1441
  async load(slug, locale) {
1442
+ const cached = ctx.state.articles.get(locale)?.get(slug);
1443
+ if (cached !== void 0) return cached;
1433
1444
  const article = await resolveArticle(ctx, slug, locale);
1434
1445
  if (article === null) throw articleNotFound(slug, locale);
1435
1446
  if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
@@ -1610,7 +1621,7 @@ const contentPlugin = createPlugin$1("content", {
1610
1621
  //#endregion
1611
1622
  //#region src/plugins/site/api.ts
1612
1623
  /** Error prefix for all site lifecycle/validation failures. */
1613
- const ERROR_PREFIX$12 = "[web]";
1624
+ const ERROR_PREFIX$13 = "[web]";
1614
1625
  /** URL protocols that qualify a parsed URL as an absolute http/https URL. */
1615
1626
  const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
1616
1627
  /**
@@ -1709,8 +1720,8 @@ function isAbsoluteUrl(value) {
1709
1720
  * ```
1710
1721
  */
1711
1722
  function validateSiteConfig(ctx) {
1712
- if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$12} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
1713
- if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$12} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
1723
+ if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$13} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
1724
+ if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$13} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
1714
1725
  }
1715
1726
  /**
1716
1727
  * Creates the site plugin API surface — read-only accessors over frozen config
@@ -1892,21 +1903,42 @@ function bySpecificity(a, b) {
1892
1903
  return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1893
1904
  }
1894
1905
  /**
1906
+ * Decode a captured group's percent-escapes so params round-trip with
1907
+ * `buildUrl`'s encoding (matchers run against the encoded `location.pathname`).
1908
+ * Falls back to the raw text on malformed escapes (never throw mid-match).
1909
+ *
1910
+ * @param value - The raw captured segment text (possibly percent-encoded).
1911
+ * @returns The decoded param value, or the raw text on malformed escapes.
1912
+ * @example
1913
+ * ```ts
1914
+ * decodeGroupValue("c%23%20tips"); // "c# tips"
1915
+ * ```
1916
+ */
1917
+ function decodeGroupValue(value) {
1918
+ try {
1919
+ return decodeURIComponent(value);
1920
+ } catch {
1921
+ return value;
1922
+ }
1923
+ }
1924
+ /**
1895
1925
  * Extract named groups from a `URLPattern` match result, dropping numeric/regex
1896
1926
  * group keys and `undefined` values so only declared, present params remain.
1927
+ * Each value is percent-DECODED ({@link decodeGroupValue}) back to the literal
1928
+ * value `buildUrl` was given.
1897
1929
  *
1898
1930
  * @param groups - The `URLPatternResult.pathname.groups` object.
1899
1931
  * @returns A clean record of named params.
1900
1932
  * @example
1901
1933
  * ```ts
1902
- * extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
1934
+ * extractGroups({ slug: "hello%20there", "0": "x" }); // { slug: "hello there" }
1903
1935
  * ```
1904
1936
  */
1905
1937
  function extractGroups(groups) {
1906
1938
  const params = {};
1907
1939
  for (const [key, value] of Object.entries(groups)) {
1908
1940
  if (/^\d+$/.test(key)) continue;
1909
- if (value !== void 0) params[key] = value;
1941
+ if (value !== void 0) params[key] = decodeGroupValue(value);
1910
1942
  }
1911
1943
  return params;
1912
1944
  }
@@ -2049,7 +2081,7 @@ function matchRoute(compiled, pathname) {
2049
2081
  //#endregion
2050
2082
  //#region src/plugins/router/builders/compile.ts
2051
2083
  /** Shared `[web]` error prefix for router validation failures. */
2052
- const ERROR_PREFIX$11 = "[web] router";
2084
+ const ERROR_PREFIX$12 = "[web] router";
2053
2085
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
2054
2086
  const MAX_LANG_SEGMENTS = 1;
2055
2087
  /**
@@ -2109,9 +2141,9 @@ function hasValidLangCount(pattern) {
2109
2141
  * ```
2110
2142
  */
2111
2143
  function assertRouteValid(name, pattern) {
2112
- if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2113
- if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2114
- if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2144
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2145
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2146
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2115
2147
  }
2116
2148
  /**
2117
2149
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
@@ -2127,7 +2159,7 @@ function assertRouteValid(name, pattern) {
2127
2159
  */
2128
2160
  function validateRoutes(routes) {
2129
2161
  const names = Object.keys(routes);
2130
- if (names.length === 0) throw new Error(`${ERROR_PREFIX$11}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
2162
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$12}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
2131
2163
  for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
2132
2164
  }
2133
2165
  /**
@@ -2161,27 +2193,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2161
2193
  return out.join("/");
2162
2194
  }
2163
2195
  /**
2164
- * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
2165
- * Walks segment-by-segment (no backtracking regex). An optional placeholder whose
2166
- * param is absent has its segment skipped entirely (no empty segment), so a missing
2167
- * `{lang:?}` collapses cleanly instead of leaving a double slash.
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).
2196
+ * Substitute a pattern's placeholders one `/`-segment at a time (no backtracking
2197
+ * regex), passing each value through `encodeValue` percent-encoding for
2198
+ * {@link buildUrl}, identity for {@link buildFilePath}. An absent optional segment
2199
+ * collapses (no double slash), as does `{lang:?}` for the bare `defaultLocale`.
2172
2200
  *
2173
2201
  * @param pattern - The route pattern.
2174
2202
  * @param params - Param values to substitute.
2175
2203
  * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2176
- * @returns The resolved relative URL string.
2204
+ * @param encodeValue - Encoder applied to each substituted param value.
2205
+ * @returns The resolved relative path string.
2177
2206
  * @example
2178
2207
  * ```ts
2179
- * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2180
- * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2181
- * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2208
+ * substitutePattern("/{slug}/", { slug: "a b" }, undefined, encodeURIComponent); // "/a%20b/"
2182
2209
  * ```
2183
2210
  */
2184
- function buildUrl(pattern, params, defaultLocale) {
2211
+ function substitutePattern(pattern, params, defaultLocale, encodeValue) {
2185
2212
  const out = [];
2186
2213
  for (const segment of pattern.split("/")) {
2187
2214
  const placeholder = parsePlaceholder(segment);
@@ -2192,24 +2219,48 @@ function buildUrl(pattern, params, defaultLocale) {
2192
2219
  const value = params[placeholder.name] ?? "";
2193
2220
  if (placeholder.optional && value === "") continue;
2194
2221
  if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2195
- out.push(value);
2222
+ out.push(encodeValue(value));
2196
2223
  }
2197
2224
  return out.join("/");
2198
2225
  }
2199
2226
  /**
2227
+ * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`;
2228
+ * segment walk in {@link substitutePattern}). Substituted values are
2229
+ * percent-encoded so reserved characters (`#`, `?`, `&`, spaces, …) cannot
2230
+ * truncate the path or break the sitemap XML, and the URL round-trips through
2231
+ * the matchers (`extractGroups` decodes captures back).
2232
+ *
2233
+ * @param pattern - The route pattern.
2234
+ * @param params - Param values to substitute.
2235
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2236
+ * @returns The resolved relative URL string.
2237
+ * @example
2238
+ * ```ts
2239
+ * buildUrl("/{slug}/", { slug: "a & b" }); // "/a%20%26%20b/"
2240
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2241
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2242
+ * ```
2243
+ */
2244
+ function buildUrl(pattern, params, defaultLocale) {
2245
+ return substitutePattern(pattern, params, defaultLocale, encodeURIComponent);
2246
+ }
2247
+ /**
2200
2248
  * Build an output file path from a pattern and params (always `…/index.html`).
2249
+ * Param values stay LITERAL: servers decode the encoded request path before
2250
+ * filesystem lookup, so on-disk names carry the decoded text.
2201
2251
  *
2202
2252
  * @param pattern - The route pattern.
2203
2253
  * @param params - Param values to substitute.
2204
- * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2254
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2205
2255
  * @returns The output file path, e.g. `hello/index.html`.
2206
2256
  * @example
2207
2257
  * ```ts
2208
- * buildFilePath("/{slug}/", { slug: "hello" });
2258
+ * buildFilePath("/{slug}/", { slug: "hello" }); // "hello/index.html"
2259
+ * buildFilePath("/{tag}/", { tag: "a & b" }); // "a & b/index.html"
2209
2260
  * ```
2210
2261
  */
2211
2262
  function buildFilePath(pattern, params, defaultLocale) {
2212
- const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2263
+ const cleanPath = substitutePattern(pattern, params, defaultLocale, (value) => value).replace(/^\//, "").replace(/\/$/, "");
2213
2264
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2214
2265
  }
2215
2266
  /**
@@ -2331,7 +2382,7 @@ function compileRoutes(input) {
2331
2382
  * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
2332
2383
  */
2333
2384
  /** Error prefix for router API failures. */
2334
- const ERROR_PREFIX$10 = "[web] router";
2385
+ const ERROR_PREFIX$11 = "[web] router";
2335
2386
  /**
2336
2387
  * Validate a route map and compile it into the matcher table on `ctx.state`,
2337
2388
  * resolving the global render `mode` + site base URL + i18n locales at call time.
@@ -2369,7 +2420,7 @@ function registerRoutes(ctx, routes) {
2369
2420
  * ```
2370
2421
  */
2371
2422
  function readTable(state) {
2372
- if (state.table === null) throw new Error(`${ERROR_PREFIX$10}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
2423
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$11}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
2373
2424
  return state.table;
2374
2425
  }
2375
2426
  /**
@@ -2453,7 +2504,7 @@ function createApi$5(ctx) {
2453
2504
  */
2454
2505
  toUrl(routeName, params) {
2455
2506
  const entry = readTable(state).byName.get(routeName);
2456
- if (!entry) throw new Error(`${ERROR_PREFIX$10}: unknown route name "${routeName}".\n Check the name matches a key in the route map registered via pluginConfigs.router.routes.`);
2507
+ if (!entry) throw new Error(`${ERROR_PREFIX$11}: unknown route name "${routeName}".\n Check the name matches a key in the route map registered via pluginConfigs.router.routes.`);
2457
2508
  return entry.toUrl(params);
2458
2509
  },
2459
2510
  /**
@@ -3012,8 +3063,11 @@ function resolveImage(image, site) {
3012
3063
  }
3013
3064
  /**
3014
3065
  * Build the per-locale `hreflang` alternates for a route, plus the `x-default`
3015
- * fallback (the route's URL with no `lang` override). Each alternate URL is the
3016
- * route's canonical URL for that locale, absolutized against the site base URL.
3066
+ * fallback (the route's URL with `lang` STRIPPED, i.e. the bare default-locale
3067
+ * URL). Each alternate URL is the route's canonical URL for that locale,
3068
+ * absolutized against the site base URL. Stripping `lang` — rather than keeping
3069
+ * the page's own locale — keeps the x-default href byte-identical across every
3070
+ * locale variant of the route, as the hreflang spec requires.
3017
3071
  *
3018
3072
  * @param locales - The supported locale codes (drives the alternate set).
3019
3073
  * @param route - The resolved route descriptor (provides `name` + `params`).
@@ -3029,7 +3083,9 @@ function buildHreflangAlternates(locales, route, router, site) {
3029
3083
  lang: locale
3030
3084
  })));
3031
3085
  });
3032
- const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
3086
+ const bareParams = { ...route.params };
3087
+ delete bareParams.lang;
3088
+ const xDefaultHref = site.canonical(router.toUrl(route.name, bareParams));
3033
3089
  alternates.push(hreflang(X_DEFAULT, xDefaultHref));
3034
3090
  return alternates;
3035
3091
  }
@@ -3206,7 +3262,7 @@ function serializeHead(elements) {
3206
3262
  * it to a string. It holds no resource and caches no subscription.
3207
3263
  */
3208
3264
  /** Error prefix for head API invariant failures. */
3209
- const ERROR_PREFIX$9 = "[web] head";
3265
+ const ERROR_PREFIX$10 = "[web] head";
3210
3266
  /**
3211
3267
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
3212
3268
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -3220,7 +3276,7 @@ const ERROR_PREFIX$9 = "[web] head";
3220
3276
  * ```
3221
3277
  */
3222
3278
  function readDefaults(state) {
3223
- if (state.defaults === null) throw new Error(`${ERROR_PREFIX$9}: defaults accessed before onInit normalized them.`);
3279
+ if (state.defaults === null) throw new Error(`${ERROR_PREFIX$10}: defaults accessed before onInit normalized them.`);
3224
3280
  return state.defaults;
3225
3281
  }
3226
3282
  /**
@@ -3288,7 +3344,7 @@ function createApi$4(ctx) {
3288
3344
  //#endregion
3289
3345
  //#region src/plugins/head/config.ts
3290
3346
  /** Error prefix for all head config-validation failures. */
3291
- const ERROR_PREFIX$8 = "[web] head";
3347
+ const ERROR_PREFIX$9 = "[web] head";
3292
3348
  /** The allowed `twitterCard` literals (also the runtime guard set). */
3293
3349
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3294
3350
  /**
@@ -3315,8 +3371,8 @@ const defaultConfig$3 = { twitterCard: "summary_large_image" };
3315
3371
  * ```
3316
3372
  */
3317
3373
  function validateHeadConfig(config) {
3318
- if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$8}: titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
3319
- if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$8}: twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
3374
+ if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$9}: titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
3375
+ if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$9}: twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
3320
3376
  }
3321
3377
  /**
3322
3378
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -3443,14 +3499,16 @@ const JS_ENTRY_CANDIDATES = [
3443
3499
  /**
3444
3500
  * The default bundler runner — adapts the built-in `Bun.build`.
3445
3501
  *
3446
- * @param options - Entry/outdir/minify settings forwarded to `Bun.build`.
3502
+ * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3447
3503
  * @param options.entrypoints - Entry files for this build.
3448
3504
  * @param options.outdir - Output directory.
3449
3505
  * @param options.minify - Whether to minify.
3506
+ * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3507
+ * @param options.target - The bundling target platform.
3450
3508
  * @returns The structural build result.
3451
3509
  * @example
3452
3510
  * ```ts
3453
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true });
3511
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3454
3512
  * ```
3455
3513
  */
3456
3514
  async function defaultRunner(options) {
@@ -3519,7 +3577,9 @@ function normalizeAssetPath(absolutePath, outDir) {
3519
3577
  }
3520
3578
  /**
3521
3579
  * Run one bundler pass for a single asset kind and record the hashed output
3522
- * paths under `state.buildCache` keyed by the original entry basename.
3580
+ * paths under `state.buildCache` keyed by the original entry basename. Lazy
3581
+ * split chunks are emitted to disk but excluded from the recorded manifest
3582
+ * (they must never be embedded as eager `<script>` tags).
3523
3583
  *
3524
3584
  * @param ctx - The phase context (state + log).
3525
3585
  * @param runner - The bundler runner to invoke.
@@ -3538,11 +3598,16 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3538
3598
  const result = await runner({
3539
3599
  entrypoints,
3540
3600
  outdir,
3541
- minify
3601
+ minify,
3602
+ splitting: true,
3603
+ target: "browser"
3542
3604
  });
3543
3605
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3544
3606
  const hashed = {};
3545
- for (const output of result.outputs) hashed[node_path$1.default.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3607
+ for (const output of result.outputs) {
3608
+ if (output.kind === "chunk") continue;
3609
+ hashed[node_path$1.default.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3610
+ }
3546
3611
  ctx.state.buildCache.set(kind, hashed);
3547
3612
  ctx.log.debug("build:bundle", {
3548
3613
  kind,
@@ -3714,10 +3779,34 @@ function createFeedChannel(site, defaultLocale) {
3714
3779
  author: { name: site.author() }
3715
3780
  });
3716
3781
  }
3782
+ /** Matches a root-relative `src`/`href` attribute opening (`="/`), excluding protocol-relative `="//`. */
3783
+ const ROOT_RELATIVE_URL_ATTR = /\b(src|href)="\/(?!\/)/g;
3784
+ /**
3785
+ * Absolutize root-relative `src`/`href` URLs in rendered article HTML against the
3786
+ * site base URL. The content pipeline rewrites co-located images to root-relative
3787
+ * paths (`/<slug>/images/...`) — fine on the site, broken inside a feed, where
3788
+ * readers do not reliably resolve relative URLs. Protocol-relative (`//host/...`)
3789
+ * and already-absolute URLs are left untouched.
3790
+ *
3791
+ * @param html - The rendered article HTML.
3792
+ * @param baseUrl - The absolute site base URL (trailing slashes tolerated).
3793
+ * @returns The HTML with every root-relative URL made absolute.
3794
+ * @example
3795
+ * ```ts
3796
+ * absolutizeContentUrls('<img src="/post/images/a.webp">', "https://blog.dev");
3797
+ * // '<img src="https://blog.dev/post/images/a.webp">'
3798
+ * ```
3799
+ */
3800
+ function absolutizeContentUrls(html, baseUrl) {
3801
+ let base = baseUrl;
3802
+ while (base.endsWith("/")) base = base.slice(0, -1);
3803
+ return html.replaceAll(ROOT_RELATIVE_URL_ATTR, (_match, attribute) => `${attribute}="${base}/`);
3804
+ }
3717
3805
  /**
3718
3806
  * Append one article to the feed and return its canonical GUID. The canonical
3719
3807
  * URL is the article's single stable identity — it is the item's id, guid, and
3720
- * link at once.
3808
+ * link at once. Item content is the rendered HTML with root-relative URLs
3809
+ * absolutized against the site base, so embedded assets resolve in feed readers.
3721
3810
  *
3722
3811
  * @param feed - The feed channel to append to (mutated in place).
3723
3812
  * @param article - The published article to add.
@@ -3736,7 +3825,7 @@ function addArticleItem(feed$1, article, site) {
3736
3825
  guid: canonicalUrl,
3737
3826
  link: canonicalUrl,
3738
3827
  description: article.frontmatter.description,
3739
- content: article.html,
3828
+ content: absolutizeContentUrls(article.html, site.url()),
3740
3829
  date: new Date(article.frontmatter.date),
3741
3830
  author: [{ name: article.frontmatter.author ?? site.author() }]
3742
3831
  });
@@ -3851,10 +3940,14 @@ async function processImages(ctx, options = {}) {
3851
3940
  //#endregion
3852
3941
  //#region src/plugins/build/phases/locale-redirects.ts
3853
3942
  /**
3854
- * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3943
+ * @file build phase — locale-redirects. For each REQUIRED-`{lang}` route (whose bare,
3944
+ * locale-less path would otherwise 404 — pages writes only `/{locale}/…`), emits a
3855
3945
  * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3856
- * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3857
- * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3946
+ * bare path that points at the default-locale-prefixed URL. OPTIONAL-`{lang:?}`
3947
+ * routes get NO redirect: the default locale is served BARE, so the pages phase
3948
+ * already writes the real content page at the bare path (plus a `/{defaultLocale}/…`
3949
+ * alias) — a redirect there would overwrite content. Deliberately does NOT emit a
3950
+ * Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3858
3951
  * `config.localeRedirects` (false/unset disables).
3859
3952
  *
3860
3953
  * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
@@ -3906,6 +3999,12 @@ function pairRoutes(router) {
3906
3999
  * redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
3907
4000
  * (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
3908
4001
  *
4002
+ * Only a REQUIRED-`{lang}` route produces a job. On an OPTIONAL-`{lang:?}` route the
4003
+ * compiled `toUrl` serves the default locale BARE (`toUrl({ lang: defaultLocale })`
4004
+ * equals the bare URL), so `target === bareUrl` → `null`. That is by design AND the
4005
+ * collision guard: the pages phase writes the default-locale content page at exactly
4006
+ * that bare file, and a redirect would overwrite it.
4007
+ *
3909
4008
  * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3910
4009
  * @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
3911
4010
  * @param defaultLocale - The default locale to redirect bare paths to.
@@ -4650,7 +4749,7 @@ function dataApi(ctx) {
4650
4749
  * ```
4651
4750
  */
4652
4751
  async write(entries, options) {
4653
- const { writeData } = await Promise.resolve().then(() => require("./writer-DV5hWB2i.cjs"));
4752
+ const { writeData } = await Promise.resolve().then(() => require("./writer-JdhX1Wld.cjs"));
4654
4753
  return writeData(ctx, entries, options);
4655
4754
  },
4656
4755
  /**
@@ -4928,29 +5027,44 @@ async function generateParameterSets(definition, locale, ctx) {
4928
5027
  * locale). The generate context is the spec `{ locale, require, has }`, so a
4929
5028
  * `.generate()` handler pulls sibling APIs the spec way.
4930
5029
  *
5030
+ * Instances are deduplicated by resolved output file: a route whose pattern has no
5031
+ * lang placeholder (or whose `generate()` params omit `lang`) resolves to the SAME
5032
+ * `toFile` path for EVERY locale — without the guard each locale's render races on
5033
+ * one output file and the shipped HTML's locale is nondeterministic. The default
5034
+ * locale is expanded FIRST, so a collapsed route keeps its default-locale instance.
5035
+ *
4931
5036
  * @param definition - The route definition from the manifest.
4932
5037
  * @param locales - Active locale codes from i18n.
5038
+ * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4933
5039
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4934
5040
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4935
- * @returns The flattened list of page instances for this route.
5041
+ * @returns The flattened, file-deduplicated list of page instances for this route.
4936
5042
  * @example
4937
5043
  * ```ts
4938
- * await expandRoute(def, ["en"], byPattern, ctx);
5044
+ * await expandRoute(def, ["en"], "en", byPattern, ctx);
4939
5045
  * ```
4940
5046
  */
4941
- async function expandRoute(definition, locales, byPattern, ctx) {
5047
+ async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4942
5048
  const entry = resolveEntry(byPattern, definition);
4943
5049
  const { name } = entry;
5050
+ const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4944
5051
  const instances = [];
4945
- for (const locale of locales) {
5052
+ const claimedFiles = /* @__PURE__ */ new Set();
5053
+ for (const locale of orderedLocales) {
4946
5054
  const parameterSets = await generateParameterSets(definition, locale, ctx);
4947
- for (const raw of parameterSets) instances.push({
4948
- definition,
4949
- entry,
4950
- name,
4951
- params: raw ?? {},
4952
- locale
4953
- });
5055
+ for (const raw of parameterSets) {
5056
+ const params = raw ?? {};
5057
+ const file = entry.toFile(params);
5058
+ if (claimedFiles.has(file)) continue;
5059
+ claimedFiles.add(file);
5060
+ instances.push({
5061
+ definition,
5062
+ entry,
5063
+ name,
5064
+ params,
5065
+ locale
5066
+ });
5067
+ }
4954
5068
  }
4955
5069
  return instances;
4956
5070
  }
@@ -5260,20 +5374,22 @@ async function prepareShell(ctx) {
5260
5374
  }
5261
5375
  /**
5262
5376
  * Expand every manifest route into its concrete page instances across all locales
5263
- * (delegating per-route expansion to {@link expandRoute}) and flatten the result.
5377
+ * (delegating per-route expansion and per-route output-file deduplication to
5378
+ * {@link expandRoute}) and flatten the result.
5264
5379
  *
5265
5380
  * @param manifest - The route definitions from `router.manifest()`.
5266
5381
  * @param locales - Active locale codes from i18n.
5382
+ * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
5267
5383
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
5268
5384
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
5269
5385
  * @returns The flattened list of page instances to render.
5270
5386
  * @example
5271
5387
  * ```ts
5272
- * const instances = await expandAllInstances(manifest, ["en"], byPattern, ctx);
5388
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
5273
5389
  * ```
5274
5390
  */
5275
- async function expandAllInstances(manifest, locales, byPattern, ctx) {
5276
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern, ctx)))).flat();
5391
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
5392
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
5277
5393
  }
5278
5394
  /**
5279
5395
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5389,7 +5505,7 @@ async function renderPages(ctx, options) {
5389
5505
  const byPattern = makeEntryMap(router);
5390
5506
  if (!reuse) ctx.state.renderCache.clear();
5391
5507
  const shell = await prepareShell(ctx);
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();
5508
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5393
5509
  await writeDataSidecars(ctx, rendered, router.mode());
5394
5510
  ctx.log.debug("build:pages", { count: rendered.length });
5395
5511
  return {
@@ -5464,7 +5580,22 @@ async function expandUrls(definition, entry, locales, ctx) {
5464
5580
  return urls;
5465
5581
  }
5466
5582
  /**
5467
- * Serialize a `<urlset>` sitemap document from a canonical URL set.
5583
+ * XML-escape a value for safe insertion into a text node (`& < > " '`). `&` is
5584
+ * escaped first so already-escaped entities are not double-escaped.
5585
+ *
5586
+ * @param raw - The unsafe string.
5587
+ * @returns The XML-escaped string.
5588
+ * @example
5589
+ * ```ts
5590
+ * escapeXml("https://blog.dev/a&b/"); // "https://blog.dev/a&amp;b/"
5591
+ * ```
5592
+ */
5593
+ function escapeXml(raw) {
5594
+ return raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
5595
+ }
5596
+ /**
5597
+ * Serialize a `<urlset>` sitemap document from a canonical URL set. Each `<loc>`
5598
+ * value is XML-escaped so slugs containing `&`/`<`/`>` cannot break the document.
5468
5599
  *
5469
5600
  * @param urls - The canonical (absolute) URLs.
5470
5601
  * @returns The serialized sitemap XML.
@@ -5474,7 +5605,7 @@ async function expandUrls(definition, entry, locales, ctx) {
5474
5605
  * ```
5475
5606
  */
5476
5607
  function serializeSitemap(urls) {
5477
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((url) => ` <url><loc>${url}</loc></url>`).join("\n")}\n</urlset>\n`;
5608
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`).join("\n")}\n</urlset>\n`;
5478
5609
  }
5479
5610
  /**
5480
5611
  * Index the compiled router entries by their URL pattern, so each manifest
@@ -5580,7 +5711,8 @@ async function generateSitemap(ctx) {
5580
5711
  const locales = ctx.require(i18nPlugin).locales();
5581
5712
  const router = ctx.require(routerPlugin);
5582
5713
  const byPattern = indexRoutesByPattern(router);
5583
- const urls = (await collectRelativeUrls(router.manifest(), byPattern, locales, ctx)).map((relative) => site.canonical(relative));
5714
+ const relativeUrls = await collectRelativeUrls(router.manifest(), byPattern, locales, ctx);
5715
+ const urls = [...new Set(relativeUrls)].map((relative) => site.canonical(relative));
5584
5716
  const xml = serializeSitemap(urls);
5585
5717
  const robots = buildRobotsTxt(site);
5586
5718
  await writeSitemapFiles(ctx.config.outDir, xml, robots);
@@ -5597,6 +5729,8 @@ async function generateSitemap(ctx) {
5597
5729
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5598
5730
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5599
5731
  */
5732
+ /** Error prefix for build pipeline runtime failures (spec/11 Part-3). */
5733
+ const ERROR_PREFIX$8 = "[web] build";
5600
5734
  /** Matches a Markdown source path (a content edit). */
5601
5735
  const MARKDOWN_PATH = /\.md$/;
5602
5736
  /** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
@@ -5632,6 +5766,46 @@ function planIncrementalRebuild(changed) {
5632
5766
  };
5633
5767
  }
5634
5768
  /**
5769
+ * Test whether a resolved path sits STRICTLY inside a resolved base directory —
5770
+ * equality does not count (the base itself is never "inside" itself).
5771
+ *
5772
+ * @param resolved - The resolved absolute candidate path.
5773
+ * @param baseResolved - The resolved absolute base directory.
5774
+ * @returns `true` when `resolved` is nested beneath `baseResolved`.
5775
+ * @example
5776
+ * ```ts
5777
+ * isStrictlyInside("/app/dist", "/app"); // true — but isStrictlyInside("/app", "/app") is false
5778
+ * ```
5779
+ */
5780
+ function isStrictlyInside(resolved, baseResolved) {
5781
+ return resolved !== baseResolved && resolved.startsWith(baseResolved + node_path$1.default.sep);
5782
+ }
5783
+ /**
5784
+ * Assert that `outDir` is a SAFE target for the clean phase's recursive force-delete,
5785
+ * defending against a misconfiguration (`outDir: "/"`, `"."`, `"~"`, a `..` escape)
5786
+ * that would otherwise wipe the filesystem root, the home directory, or the project
5787
+ * itself. Mirrors the deploy plugin's `assertWithinRoot` posture, tightened for
5788
+ * deletion: a target is safe only when it sits STRICTLY inside the project root
5789
+ * (never the root itself) or strictly inside the OS temp directory (a disposable
5790
+ * area, used by preview/test builds) — and is never the home directory.
5791
+ *
5792
+ * @param outDir - The configured output directory (relative or absolute).
5793
+ * @param root - The absolute project root relative paths resolve against.
5794
+ * @returns The resolved absolute output directory.
5795
+ * @throws {Error} `[web] build.outDir` when the resolved target is unsafe to delete.
5796
+ * @example
5797
+ * ```ts
5798
+ * assertSafeCleanTarget("./dist", process.cwd()); // "<cwd>/dist"
5799
+ * ```
5800
+ */
5801
+ function assertSafeCleanTarget(outDir, root) {
5802
+ const resolved = node_path$1.default.isAbsolute(outDir) ? node_path$1.default.resolve(outDir) : node_path$1.default.resolve(root, outDir);
5803
+ const rootResolved = node_path$1.default.resolve(root);
5804
+ const isHome = resolved === node_path$1.default.resolve((0, node_os.homedir)());
5805
+ if ((isStrictlyInside(resolved, rootResolved) || isStrictlyInside(resolved, node_path$1.default.resolve((0, node_os.tmpdir)()))) && !isHome) return resolved;
5806
+ throw new Error(`${ERROR_PREFIX$8}.outDir: ${JSON.stringify(outDir)} (resolves to ${JSON.stringify(resolved)}) is not a safe clean target.\n The clean phase force-deletes outDir recursively, so it must sit strictly inside the project root ${JSON.stringify(rootResolved)} (or the OS temp directory) — never the filesystem root, your home directory, or the project root itself.\n Point build.outDir at a directory inside the project, e.g. "./dist".`);
5807
+ }
5808
+ /**
5635
5809
  * The static ordered list of pipeline phase names.
5636
5810
  *
5637
5811
  * @example
@@ -5681,17 +5855,23 @@ async function withPhase(ctx, phase, work) {
5681
5855
  }
5682
5856
  /**
5683
5857
  * Reset the per-run state (manifest, buildCache, runId) and assign a fresh runId.
5858
+ * A clean run (no `skipClean`) also drops the OG image hash cache: the outDir wipe
5859
+ * deletes every `og/<slug>.png` the cache indexes, so honoring those warm entries
5860
+ * would skip rendering files that no longer exist. A `skipClean` (dev) run keeps
5861
+ * the cache — its PNGs survive on disk.
5684
5862
  *
5685
5863
  * @param ctx - The phase context whose `state` is reset.
5864
+ * @param options - The run options (only `skipClean` is consulted).
5686
5865
  * @example
5687
5866
  * ```ts
5688
- * resetRun(ctx);
5867
+ * resetRun(ctx, options);
5689
5868
  * ```
5690
5869
  */
5691
- function resetRun(ctx) {
5870
+ function resetRun(ctx, options) {
5692
5871
  ctx.state.manifest = null;
5693
5872
  ctx.state.buildCache = /* @__PURE__ */ new Map();
5694
5873
  ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
5874
+ if (!options?.skipClean) ctx.state.ogImageHashCache.clear();
5695
5875
  }
5696
5876
  /**
5697
5877
  * Report each rejected outcome from a settled output batch as a `build:outputs`
@@ -5753,7 +5933,7 @@ async function runOutputs(ctx) {
5753
5933
  */
5754
5934
  async function runPipeline(ctx, options) {
5755
5935
  const started = Date.now();
5756
- resetRun(ctx);
5936
+ resetRun(ctx, options);
5757
5937
  const outDir = options?.outDir ?? ctx.config.outDir;
5758
5938
  const phaseContext = {
5759
5939
  ...ctx,
@@ -5764,10 +5944,13 @@ async function runPipeline(ctx, options) {
5764
5944
  }
5765
5945
  };
5766
5946
  const plan = planIncrementalRebuild(options?.changed);
5767
- if (!options?.skipClean) await (0, node_fs_promises.rm)(outDir, {
5768
- recursive: true,
5769
- force: true
5770
- });
5947
+ if (!options?.skipClean) {
5948
+ assertSafeCleanTarget(outDir, process.cwd());
5949
+ await (0, node_fs_promises.rm)(outDir, {
5950
+ recursive: true,
5951
+ force: true
5952
+ });
5953
+ }
5771
5954
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
5772
5955
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5773
5956
  await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
@@ -10063,6 +10246,23 @@ function isInternalLink(url) {
10063
10246
  return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
10064
10247
  }
10065
10248
  /**
10249
+ * The navigable path of a URL or Location: pathname plus query string. The query
10250
+ * is part of page identity (the kernel's `currentUrl` is pathname + search), so
10251
+ * same-page checks, history entries, fetches, and scroll keys must all carry it —
10252
+ * comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
10253
+ * and the History fallback would drop the query from the address bar.
10254
+ *
10255
+ * @param target - The URL or Location to read.
10256
+ * @param target.pathname - The path component.
10257
+ * @param target.search - The query-string component (`""` when absent).
10258
+ * @returns The pathname + search string.
10259
+ * @example
10260
+ * pathWithSearch(new URL("https://x.dev/search?q=a")); // "/search?q=a"
10261
+ */
10262
+ function pathWithSearch(target) {
10263
+ return target.pathname + target.search;
10264
+ }
10265
+ /**
10066
10266
  * Save the current scroll position keyed by path (best-effort; ignores storage errors).
10067
10267
  *
10068
10268
  * @param path - The path to key the scroll position under.
@@ -10091,19 +10291,27 @@ function restoreScrollPosition(path) {
10091
10291
  * Fetch a page and hand its HTML to the handlers; on any error fall back to a
10092
10292
  * full browser navigation (`location.href = pathname`).
10093
10293
  *
10294
+ * When `signal` aborts (this navigation was superseded by a newer one) the
10295
+ * fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
10296
+ * reload — the live navigation owns the document from that point on.
10297
+ *
10094
10298
  * @param pathname - The destination pathname.
10095
10299
  * @param handlers - The navigation lifecycle callbacks.
10300
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10096
10301
  * @returns A promise that resolves once the swap (or fallback) is dispatched.
10097
10302
  * @example
10098
- * await performNavigation("/about", handlers);
10303
+ * await performNavigation("/about", handlers, navEvent.signal);
10099
10304
  */
10100
- async function performNavigation(pathname, handlers) {
10305
+ async function performNavigation(pathname, handlers, signal) {
10101
10306
  handlers.onStart(pathname);
10102
10307
  try {
10103
- const response = await fetch(pathname);
10308
+ const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10104
10309
  if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10105
- handlers.onEnd(await response.text(), pathname);
10310
+ const html = await response.text();
10311
+ if (signal?.aborted) return;
10312
+ handlers.onEnd(html, pathname);
10106
10313
  } catch {
10314
+ if (signal?.aborted) return;
10107
10315
  handlers.onError();
10108
10316
  location.href = pathname;
10109
10317
  }
@@ -10142,23 +10350,29 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
10142
10350
  * inside the same transition frame (after the DOM mutation) so component
10143
10351
  * re-mounting is captured by the transition snapshot.
10144
10352
  *
10353
+ * Returns whether the swap was dispatched: `false` when either document lacks
10354
+ * the `swapSelector` region, so the caller can fall back to a full navigation
10355
+ * instead of finishing the SPA nav against an un-swapped body.
10356
+ *
10145
10357
  * @param doc - The fetched document (DOMParser-parsed) holding the new region.
10146
10358
  * @param swapSelector - CSS selector for the region to replace.
10147
10359
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10148
10360
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10149
10361
  * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10150
10362
  * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10363
+ * @returns `true` when the swap was dispatched, `false` when either document lacks the region.
10151
10364
  * @example
10152
10365
  * swapRegion(doc, "main > section", false, () => mountNew());
10153
10366
  */
10154
10367
  function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10155
10368
  const newContent = doc.querySelector(swapSelector);
10156
10369
  const currentContent = document.querySelector(swapSelector);
10157
- if (!newContent || !currentContent) return;
10370
+ if (!newContent || !currentContent) return false;
10158
10371
  runSwap(() => {
10159
10372
  currentContent.replaceWith(newContent);
10160
10373
  onSwapped();
10161
10374
  }, viewTransitions, beforeCapture);
10375
+ return true;
10162
10376
  }
10163
10377
  /**
10164
10378
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10192,7 +10406,20 @@ function resolveClickTarget(event) {
10192
10406
  * @example
10193
10407
  * const dispose = attachHistoryFallback(handlers);
10194
10408
  */
10195
- function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
10409
+ function attachHistoryFallback(handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
10410
+ let controller;
10411
+ /**
10412
+ * Supersede the in-flight navigation (if any) and mint the next one's abort signal.
10413
+ *
10414
+ * @returns The fresh navigation's abort signal.
10415
+ * @example
10416
+ * const signal = supersede();
10417
+ */
10418
+ const supersede = () => {
10419
+ controller?.abort();
10420
+ controller = new AbortController();
10421
+ return controller.signal;
10422
+ };
10196
10423
  /**
10197
10424
  * Intercept an internal-link click and run a History-API navigation.
10198
10425
  *
@@ -10203,17 +10430,18 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10203
10430
  const onClick = (event) => {
10204
10431
  const url = resolveClickTarget(event);
10205
10432
  if (!url) return;
10433
+ if (url.pathname === location.pathname && url.hash) return;
10206
10434
  event.preventDefault();
10207
- if (url.pathname === location.pathname) {
10435
+ if (pathWithSearch(url) === pathWithSearch(location)) {
10208
10436
  window.scrollTo({
10209
10437
  top: 0,
10210
10438
  behavior: "smooth"
10211
10439
  });
10212
10440
  return;
10213
10441
  }
10214
- saveScrollPosition(location.pathname);
10215
- history.pushState({ scrollY: 0 }, "", url.pathname);
10216
- navigate(url.pathname).catch(() => {});
10442
+ saveScrollPosition(pathWithSearch(location));
10443
+ history.pushState({ scrollY: 0 }, "", pathWithSearch(url));
10444
+ navigate(pathWithSearch(url), true, supersede()).catch(() => {});
10217
10445
  };
10218
10446
  /**
10219
10447
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10222,7 +10450,11 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10222
10450
  * globalThis.addEventListener("popstate", onPopState);
10223
10451
  */
10224
10452
  const onPopState = () => {
10225
- navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10453
+ const path = pathWithSearch(location);
10454
+ const signal = supersede();
10455
+ navigate(path, false, signal).then(() => {
10456
+ if (!signal.aborted) restoreScrollPosition(path);
10457
+ }).catch(() => {});
10226
10458
  };
10227
10459
  document.addEventListener("click", onClick);
10228
10460
  globalThis.addEventListener("popstate", onPopState);
@@ -10241,7 +10473,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10241
10473
  * @example
10242
10474
  * const dispose = attachNavigationApi(navigation, handlers);
10243
10475
  */
10244
- function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
10476
+ function attachNavigationApi(navigation, handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
10245
10477
  /**
10246
10478
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
10247
10479
  *
@@ -10253,7 +10485,7 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10253
10485
  const url = new URL(navEvent.destination.url);
10254
10486
  if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
10255
10487
  if (!isInternalLink(url)) return;
10256
- if (url.pathname === location.pathname) {
10488
+ if (pathWithSearch(url) === pathWithSearch(location)) {
10257
10489
  navEvent.intercept({ handler: () => {
10258
10490
  window.scrollTo({
10259
10491
  top: 0,
@@ -10267,9 +10499,9 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10267
10499
  scroll: "manual",
10268
10500
  handler: async () => {
10269
10501
  if (navEvent.navigationType === "traverse") {
10270
- await navigate(url.pathname, false);
10271
- navEvent.scroll();
10272
- } else await navigate(url.pathname);
10502
+ await navigate(pathWithSearch(url), false, navEvent.signal);
10503
+ if (!navEvent.signal.aborted) navEvent.scroll();
10504
+ } else await navigate(pathWithSearch(url), true, navEvent.signal);
10273
10505
  }
10274
10506
  });
10275
10507
  };
@@ -10433,22 +10665,33 @@ function createSpaKernel(state, config, emit, deps) {
10433
10665
  * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
10434
10666
  * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
10435
10667
  * 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.
10668
+ * no pre-fetch scroll pause. Traverse (back/forward) sets `pendingScrollToTop = false`
10669
+ * and restores its saved position after the swap instead.
10670
+ *
10671
+ * Scroll behaviour: `"instant"` ONLY when view transitions are enabled — that is what
10672
+ * keeps scrollY=0 in the captured snapshot (a `scroll-behavior: smooth` would otherwise
10673
+ * animate the reset and re-create the delta → sticky-header flicker). With view
10674
+ * transitions OFF there is no snapshot to protect, so it honours the page's
10675
+ * `scroll-behavior` (`"auto"` = use the CSS value, e.g. a smooth scroll-to-top on nav).
10440
10676
  *
10441
10677
  * @example
10442
10678
  * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10443
10679
  */
10444
10680
  const applyPendingScroll = () => {
10445
- if (pendingScrollToTop) window.scrollTo({
10681
+ if (!pendingScrollToTop) return;
10682
+ const behavior = resolved.viewTransitions ? "instant" : "auto";
10683
+ window.scrollTo({
10446
10684
  top: 0,
10447
- behavior: "instant"
10685
+ behavior
10448
10686
  });
10449
10687
  };
10450
10688
  /**
10451
10689
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10690
+ * When the region cannot be swapped (either document lacks the swap selector)
10691
+ * the SPA nav cannot complete — the head is already synced and the islands torn
10692
+ * down, so finishing would leave the OLD body under a NEW URL with a `spa:navigated`
10693
+ * claiming success. Fall back to a full browser navigation instead (mirroring
10694
+ * {@link performNavigation}'s fetch-error fallback).
10452
10695
  *
10453
10696
  * @param html - The fetched page HTML.
10454
10697
  * @param pathname - The destination pathname.
@@ -10459,10 +10702,14 @@ function createSpaKernel(state, config, emit, deps) {
10459
10702
  const doc = new DOMParser().parseFromString(html, "text/html");
10460
10703
  syncHead(deps.head, doc);
10461
10704
  unmountPageSpecific(state, emit);
10462
- swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10705
+ if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10463
10706
  scanAndMount(state, emit, resolved.swapSelector);
10464
10707
  notifyNavEnd(state);
10465
- }, applyPendingScroll);
10708
+ }, applyPendingScroll)) {
10709
+ handleError();
10710
+ location.href = pathname;
10711
+ return;
10712
+ }
10466
10713
  state.currentUrl = pathname;
10467
10714
  progress?.done();
10468
10715
  emit("spa:navigated", { url: pathname });
@@ -10542,13 +10789,16 @@ function createSpaKernel(state, config, emit, deps) {
10542
10789
  *
10543
10790
  * @param pathname - The destination pathname (recorded as the new current URL).
10544
10791
  * @param resolvedRender - The inputs produced by {@link resolveDataRender}.
10792
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10545
10793
  * @example
10546
10794
  * await commitDataRender("/en/world/", resolved);
10547
10795
  */
10548
- const commitDataRender = async (pathname, resolvedRender) => {
10796
+ const commitDataRender = async (pathname, resolvedRender, signal) => {
10797
+ if (signal?.aborted) return;
10549
10798
  const { route, vnode, routeContext, region } = resolvedRender;
10550
10799
  handleStart(pathname);
10551
10800
  const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10801
+ if (signal?.aborted) return;
10552
10802
  syncDataHead(route, routeContext);
10553
10803
  unmountPageSpecific(state, emit);
10554
10804
  /**
@@ -10580,15 +10830,16 @@ function createSpaKernel(state, config, emit, deps) {
10580
10830
  * to HTML-over-fetch.
10581
10831
  *
10582
10832
  * @param pathname - The destination pathname (search stripped for matching).
10833
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10583
10834
  * @returns `true` if the route was rendered from its data, else `false`.
10584
10835
  * @example
10585
10836
  * if (await tryDataRender("/en/world/")) return;
10586
10837
  */
10587
- const tryDataRender = async (pathname) => {
10838
+ const tryDataRender = async (pathname, signal) => {
10588
10839
  try {
10589
10840
  const resolvedRender = await resolveDataRender(pathname);
10590
10841
  if (resolvedRender === false) return false;
10591
- await commitDataRender(pathname, resolvedRender);
10842
+ await commitDataRender(pathname, resolvedRender, signal);
10592
10843
  return true;
10593
10844
  } catch {
10594
10845
  progress?.done();
@@ -10604,14 +10855,17 @@ function createSpaKernel(state, config, emit, deps) {
10604
10855
  * @param pathname - The destination pathname.
10605
10856
  * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10606
10857
  * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10858
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
10859
+ * a superseded navigation never applies its swap (no stale last-write-wins).
10607
10860
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10608
10861
  * @example
10609
10862
  * await navigate("/en/world/");
10610
10863
  */
10611
- const navigate = async (pathname, scrollToTop = true) => {
10864
+ const navigate = async (pathname, scrollToTop = true, signal) => {
10612
10865
  pendingScrollToTop = scrollToTop;
10613
- if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10614
- await performNavigation(pathname, handlers);
10866
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
10867
+ if (signal?.aborted) return;
10868
+ await performNavigation(pathname, handlers, signal);
10615
10869
  };
10616
10870
  return {
10617
10871
  /**
@@ -11212,9 +11466,12 @@ function defaultRehypePlugins() {
11212
11466
  * Clones the library default and additively allowlists the markup our custom
11213
11467
  * transforms emit: `class` values (`pull-quote`, `section-divider`,
11214
11468
  * `section-divider-ornament`) on `aside`/`div`/`span`, and the `loading`
11215
- * attribute on `img`. `class`/`className`/`style` are allowlisted globally (`*`,
11216
- * i.e. on every element) not just on `pre`/`code`/`span` so Shiki's inline
11217
- * token colors survive the sanitize pass.
11469
+ * attribute on `img`. `class`/`className` are allowlisted globally (`*`, i.e.
11470
+ * on every element) so Shiki's class hooks survive the sanitize pass. `style`
11471
+ * is deliberately NOT global CSS values are not sanitized, so a global
11472
+ * `style` allowlist would let untrusted content run overlay/exfiltration
11473
+ * styling; it is allowed only on `pre`/`code`, where Shiki places its
11474
+ * block-level theme background/foreground.
11218
11475
  *
11219
11476
  * @returns The extended, security-hardened sanitize schema.
11220
11477
  * @example
@@ -11245,8 +11502,7 @@ function buildSanitizeSchema() {
11245
11502
  "*": [
11246
11503
  ...baseAttributes["*"] ?? [],
11247
11504
  "className",
11248
- "class",
11249
- "style"
11505
+ "class"
11250
11506
  ],
11251
11507
  aside: [
11252
11508
  ...baseAttributes.aside ?? [],