@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/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
- import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
2
+ import { n as relativeDataFile, t as dataSuffix } from "./convention-Dp650o3y.mjs";
3
3
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
4
4
  import { appendFileSync, existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
+ import { homedir, networkInterfaces, tmpdir } from "node:os";
7
8
  import path from "node:path";
8
9
  import { Feed } from "feed";
9
10
  import { h } from "preact";
@@ -11,7 +12,6 @@ import { renderToString } from "preact-render-to-string";
11
12
  import { execFileSync } from "node:child_process";
12
13
  import { createInterface } from "node:readline";
13
14
  import { fileURLToPath } from "node:url";
14
- import { networkInterfaces } from "node:os";
15
15
  import matter from "gray-matter";
16
16
  import rehypeShiki from "@shikijs/rehype";
17
17
  import rehypeSanitize from "rehype-sanitize";
@@ -28,7 +28,7 @@ import { defaultSchema } from "hast-util-sanitize";
28
28
  import readingTime from "reading-time";
29
29
  //#region src/plugins/env/api.ts
30
30
  /** Error prefix for all env API failures. */
31
- const ERROR_PREFIX$15 = "[web]";
31
+ const ERROR_PREFIX$16 = "[web]";
32
32
  /**
33
33
  * Creates the env plugin API surface mounted at `ctx.env`. Closes over
34
34
  * `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
@@ -72,7 +72,7 @@ function createEnvApi(ctx) {
72
72
  */
73
73
  require(key) {
74
74
  const value = resolved.get(key);
75
- if (value === void 0) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined.`);
75
+ if (value === void 0) throw new Error(`${ERROR_PREFIX$16} env: required variable "${key}" is not defined.`);
76
76
  return value;
77
77
  },
78
78
  /**
@@ -139,7 +139,7 @@ function createEnvState() {
139
139
  /** Error message thrown by every frozen-map mutator. */
140
140
  const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
141
141
  /** Error prefix for all resolution-pipeline failures. */
142
- const ERROR_PREFIX$14 = "[web]";
142
+ const ERROR_PREFIX$15 = "[web]";
143
143
  /** The `Map` mutators redefined as throwers when a map is frozen. */
144
144
  const FROZEN_METHODS = [
145
145
  "set",
@@ -208,8 +208,8 @@ function crossCheckPublicPrefix(config) {
208
208
  const { schema, publicPrefix } = config;
209
209
  for (const [key, spec] of Object.entries(schema)) {
210
210
  const hasPrefix = key.startsWith(publicPrefix);
211
- if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$14} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
212
- if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$14} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
211
+ if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$15} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
212
+ if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$15} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
213
213
  }
214
214
  }
215
215
  /**
@@ -291,7 +291,7 @@ function validateSchema(ctx) {
291
291
  crossCheckPublicPrefix(config);
292
292
  for (const [key, spec] of Object.entries(schema)) {
293
293
  if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
294
- 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.`);
294
+ 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.`);
295
295
  }
296
296
  populatePublicMap(schema, merged, state.publicMap);
297
297
  populateResolved(merged, state.resolved);
@@ -899,7 +899,7 @@ const createCore = coreConfig.createCore;
899
899
  //#endregion
900
900
  //#region src/plugins/i18n/api.ts
901
901
  /** Error prefix for all i18n lifecycle failures. */
902
- const ERROR_PREFIX$13 = "[web]";
902
+ const ERROR_PREFIX$14 = "[web]";
903
903
  /**
904
904
  * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
905
905
  * `locales` is empty or when `defaultLocale` is not a member of `locales`.
@@ -915,8 +915,8 @@ const ERROR_PREFIX$13 = "[web]";
915
915
  */
916
916
  function validateI18nConfig(ctx) {
917
917
  const { locales, defaultLocale } = ctx.config;
918
- 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"].`);
919
- 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.`);
918
+ 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"].`);
919
+ 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.`);
920
920
  }
921
921
  /**
922
922
  * Creates the i18n plugin API surface — locale registry accessors plus the
@@ -1406,6 +1406,15 @@ function createContentApi(ctx) {
1406
1406
  * suppressed and throws the SAME not-found error (drafts indistinguishable from
1407
1407
  * missing); in development and test drafts load normally.
1408
1408
  *
1409
+ * Cache-first: when a preceding `loadAll()` (or earlier `load()`) already resolved +
1410
+ * rendered this `(slug, locale)`, the cached Article (full html included) is returned
1411
+ * without re-running the Markdown/Shiki pipeline — during a full build every
1412
+ * per-article route loader would otherwise re-render an article `loadAll()` just
1413
+ * rendered. Draft semantics are preserved: in production `loadAll()` filters drafts
1414
+ * out BEFORE caching and the production `load()` path throws before caching, so a
1415
+ * production cache hit is never a draft; misses fall through to a fresh resolve,
1416
+ * which suppresses drafts exactly as before.
1417
+ *
1409
1418
  * @param slug - Article directory name.
1410
1419
  * @param locale - Requested locale code.
1411
1420
  * @returns The resolved Article.
@@ -1417,6 +1426,8 @@ function createContentApi(ctx) {
1417
1426
  * ```
1418
1427
  */
1419
1428
  async load(slug, locale) {
1429
+ const cached = ctx.state.articles.get(locale)?.get(slug);
1430
+ if (cached !== void 0) return cached;
1420
1431
  const article = await resolveArticle(ctx, slug, locale);
1421
1432
  if (article === null) throw articleNotFound(slug, locale);
1422
1433
  if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
@@ -1597,7 +1608,7 @@ const contentPlugin = createPlugin$1("content", {
1597
1608
  //#endregion
1598
1609
  //#region src/plugins/site/api.ts
1599
1610
  /** Error prefix for all site lifecycle/validation failures. */
1600
- const ERROR_PREFIX$12 = "[web]";
1611
+ const ERROR_PREFIX$13 = "[web]";
1601
1612
  /** URL protocols that qualify a parsed URL as an absolute http/https URL. */
1602
1613
  const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
1603
1614
  /**
@@ -1696,8 +1707,8 @@ function isAbsoluteUrl(value) {
1696
1707
  * ```
1697
1708
  */
1698
1709
  function validateSiteConfig(ctx) {
1699
- 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.`);
1700
- 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".`);
1710
+ 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.`);
1711
+ 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".`);
1701
1712
  }
1702
1713
  /**
1703
1714
  * Creates the site plugin API surface — read-only accessors over frozen config
@@ -1879,21 +1890,42 @@ function bySpecificity(a, b) {
1879
1890
  return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1880
1891
  }
1881
1892
  /**
1893
+ * Decode a captured group's percent-escapes so params round-trip with
1894
+ * `buildUrl`'s encoding (matchers run against the encoded `location.pathname`).
1895
+ * Falls back to the raw text on malformed escapes (never throw mid-match).
1896
+ *
1897
+ * @param value - The raw captured segment text (possibly percent-encoded).
1898
+ * @returns The decoded param value, or the raw text on malformed escapes.
1899
+ * @example
1900
+ * ```ts
1901
+ * decodeGroupValue("c%23%20tips"); // "c# tips"
1902
+ * ```
1903
+ */
1904
+ function decodeGroupValue(value) {
1905
+ try {
1906
+ return decodeURIComponent(value);
1907
+ } catch {
1908
+ return value;
1909
+ }
1910
+ }
1911
+ /**
1882
1912
  * Extract named groups from a `URLPattern` match result, dropping numeric/regex
1883
1913
  * group keys and `undefined` values so only declared, present params remain.
1914
+ * Each value is percent-DECODED ({@link decodeGroupValue}) back to the literal
1915
+ * value `buildUrl` was given.
1884
1916
  *
1885
1917
  * @param groups - The `URLPatternResult.pathname.groups` object.
1886
1918
  * @returns A clean record of named params.
1887
1919
  * @example
1888
1920
  * ```ts
1889
- * extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
1921
+ * extractGroups({ slug: "hello%20there", "0": "x" }); // { slug: "hello there" }
1890
1922
  * ```
1891
1923
  */
1892
1924
  function extractGroups(groups) {
1893
1925
  const params = {};
1894
1926
  for (const [key, value] of Object.entries(groups)) {
1895
1927
  if (/^\d+$/.test(key)) continue;
1896
- if (value !== void 0) params[key] = value;
1928
+ if (value !== void 0) params[key] = decodeGroupValue(value);
1897
1929
  }
1898
1930
  return params;
1899
1931
  }
@@ -2036,7 +2068,7 @@ function matchRoute(compiled, pathname) {
2036
2068
  //#endregion
2037
2069
  //#region src/plugins/router/builders/compile.ts
2038
2070
  /** Shared `[web]` error prefix for router validation failures. */
2039
- const ERROR_PREFIX$11 = "[web] router";
2071
+ const ERROR_PREFIX$12 = "[web] router";
2040
2072
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
2041
2073
  const MAX_LANG_SEGMENTS = 1;
2042
2074
  /**
@@ -2096,9 +2128,9 @@ function hasValidLangCount(pattern) {
2096
2128
  * ```
2097
2129
  */
2098
2130
  function assertRouteValid(name, pattern) {
2099
- if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2100
- if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2101
- if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2131
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2132
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2133
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2102
2134
  }
2103
2135
  /**
2104
2136
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
@@ -2114,7 +2146,7 @@ function assertRouteValid(name, pattern) {
2114
2146
  */
2115
2147
  function validateRoutes(routes) {
2116
2148
  const names = Object.keys(routes);
2117
- if (names.length === 0) throw new Error(`${ERROR_PREFIX$11}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
2149
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$12}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
2118
2150
  for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
2119
2151
  }
2120
2152
  /**
@@ -2148,27 +2180,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2148
2180
  return out.join("/");
2149
2181
  }
2150
2182
  /**
2151
- * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
2152
- * Walks segment-by-segment (no backtracking regex). An optional placeholder whose
2153
- * param is absent has its segment skipped entirely (no empty segment), so a missing
2154
- * `{lang:?}` collapses cleanly instead of leaving a double slash.
2155
- *
2156
- * The default locale is served at BARE paths: when `defaultLocale` is given, the
2157
- * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
2158
- * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
2183
+ * Substitute a pattern's placeholders one `/`-segment at a time (no backtracking
2184
+ * regex), passing each value through `encodeValue` percent-encoding for
2185
+ * {@link buildUrl}, identity for {@link buildFilePath}. An absent optional segment
2186
+ * collapses (no double slash), as does `{lang:?}` for the bare `defaultLocale`.
2159
2187
  *
2160
2188
  * @param pattern - The route pattern.
2161
2189
  * @param params - Param values to substitute.
2162
2190
  * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2163
- * @returns The resolved relative URL string.
2191
+ * @param encodeValue - Encoder applied to each substituted param value.
2192
+ * @returns The resolved relative path string.
2164
2193
  * @example
2165
2194
  * ```ts
2166
- * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2167
- * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2168
- * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2195
+ * substitutePattern("/{slug}/", { slug: "a b" }, undefined, encodeURIComponent); // "/a%20b/"
2169
2196
  * ```
2170
2197
  */
2171
- function buildUrl(pattern, params, defaultLocale) {
2198
+ function substitutePattern(pattern, params, defaultLocale, encodeValue) {
2172
2199
  const out = [];
2173
2200
  for (const segment of pattern.split("/")) {
2174
2201
  const placeholder = parsePlaceholder(segment);
@@ -2179,24 +2206,48 @@ function buildUrl(pattern, params, defaultLocale) {
2179
2206
  const value = params[placeholder.name] ?? "";
2180
2207
  if (placeholder.optional && value === "") continue;
2181
2208
  if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2182
- out.push(value);
2209
+ out.push(encodeValue(value));
2183
2210
  }
2184
2211
  return out.join("/");
2185
2212
  }
2186
2213
  /**
2214
+ * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`;
2215
+ * segment walk in {@link substitutePattern}). Substituted values are
2216
+ * percent-encoded so reserved characters (`#`, `?`, `&`, spaces, …) cannot
2217
+ * truncate the path or break the sitemap XML, and the URL round-trips through
2218
+ * the matchers (`extractGroups` decodes captures back).
2219
+ *
2220
+ * @param pattern - The route pattern.
2221
+ * @param params - Param values to substitute.
2222
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2223
+ * @returns The resolved relative URL string.
2224
+ * @example
2225
+ * ```ts
2226
+ * buildUrl("/{slug}/", { slug: "a & b" }); // "/a%20%26%20b/"
2227
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2228
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2229
+ * ```
2230
+ */
2231
+ function buildUrl(pattern, params, defaultLocale) {
2232
+ return substitutePattern(pattern, params, defaultLocale, encodeURIComponent);
2233
+ }
2234
+ /**
2187
2235
  * Build an output file path from a pattern and params (always `…/index.html`).
2236
+ * Param values stay LITERAL: servers decode the encoded request path before
2237
+ * filesystem lookup, so on-disk names carry the decoded text.
2188
2238
  *
2189
2239
  * @param pattern - The route pattern.
2190
2240
  * @param params - Param values to substitute.
2191
- * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2241
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2192
2242
  * @returns The output file path, e.g. `hello/index.html`.
2193
2243
  * @example
2194
2244
  * ```ts
2195
- * buildFilePath("/{slug}/", { slug: "hello" });
2245
+ * buildFilePath("/{slug}/", { slug: "hello" }); // "hello/index.html"
2246
+ * buildFilePath("/{tag}/", { tag: "a & b" }); // "a & b/index.html"
2196
2247
  * ```
2197
2248
  */
2198
2249
  function buildFilePath(pattern, params, defaultLocale) {
2199
- const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2250
+ const cleanPath = substitutePattern(pattern, params, defaultLocale, (value) => value).replace(/^\//, "").replace(/\/$/, "");
2200
2251
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2201
2252
  }
2202
2253
  /**
@@ -2318,7 +2369,7 @@ function compileRoutes(input) {
2318
2369
  * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
2319
2370
  */
2320
2371
  /** Error prefix for router API failures. */
2321
- const ERROR_PREFIX$10 = "[web] router";
2372
+ const ERROR_PREFIX$11 = "[web] router";
2322
2373
  /**
2323
2374
  * Validate a route map and compile it into the matcher table on `ctx.state`,
2324
2375
  * resolving the global render `mode` + site base URL + i18n locales at call time.
@@ -2356,7 +2407,7 @@ function registerRoutes(ctx, routes) {
2356
2407
  * ```
2357
2408
  */
2358
2409
  function readTable(state) {
2359
- if (state.table === null) throw new Error(`${ERROR_PREFIX$10}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
2410
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$11}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
2360
2411
  return state.table;
2361
2412
  }
2362
2413
  /**
@@ -2440,7 +2491,7 @@ function createApi$5(ctx) {
2440
2491
  */
2441
2492
  toUrl(routeName, params) {
2442
2493
  const entry = readTable(state).byName.get(routeName);
2443
- 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.`);
2494
+ 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.`);
2444
2495
  return entry.toUrl(params);
2445
2496
  },
2446
2497
  /**
@@ -2999,8 +3050,11 @@ function resolveImage(image, site) {
2999
3050
  }
3000
3051
  /**
3001
3052
  * Build the per-locale `hreflang` alternates for a route, plus the `x-default`
3002
- * fallback (the route's URL with no `lang` override). Each alternate URL is the
3003
- * route's canonical URL for that locale, absolutized against the site base URL.
3053
+ * fallback (the route's URL with `lang` STRIPPED, i.e. the bare default-locale
3054
+ * URL). Each alternate URL is the route's canonical URL for that locale,
3055
+ * absolutized against the site base URL. Stripping `lang` — rather than keeping
3056
+ * the page's own locale — keeps the x-default href byte-identical across every
3057
+ * locale variant of the route, as the hreflang spec requires.
3004
3058
  *
3005
3059
  * @param locales - The supported locale codes (drives the alternate set).
3006
3060
  * @param route - The resolved route descriptor (provides `name` + `params`).
@@ -3016,7 +3070,9 @@ function buildHreflangAlternates(locales, route, router, site) {
3016
3070
  lang: locale
3017
3071
  })));
3018
3072
  });
3019
- const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
3073
+ const bareParams = { ...route.params };
3074
+ delete bareParams.lang;
3075
+ const xDefaultHref = site.canonical(router.toUrl(route.name, bareParams));
3020
3076
  alternates.push(hreflang(X_DEFAULT, xDefaultHref));
3021
3077
  return alternates;
3022
3078
  }
@@ -3193,7 +3249,7 @@ function serializeHead(elements) {
3193
3249
  * it to a string. It holds no resource and caches no subscription.
3194
3250
  */
3195
3251
  /** Error prefix for head API invariant failures. */
3196
- const ERROR_PREFIX$9 = "[web] head";
3252
+ const ERROR_PREFIX$10 = "[web] head";
3197
3253
  /**
3198
3254
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
3199
3255
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -3207,7 +3263,7 @@ const ERROR_PREFIX$9 = "[web] head";
3207
3263
  * ```
3208
3264
  */
3209
3265
  function readDefaults(state) {
3210
- if (state.defaults === null) throw new Error(`${ERROR_PREFIX$9}: defaults accessed before onInit normalized them.`);
3266
+ if (state.defaults === null) throw new Error(`${ERROR_PREFIX$10}: defaults accessed before onInit normalized them.`);
3211
3267
  return state.defaults;
3212
3268
  }
3213
3269
  /**
@@ -3275,7 +3331,7 @@ function createApi$4(ctx) {
3275
3331
  //#endregion
3276
3332
  //#region src/plugins/head/config.ts
3277
3333
  /** Error prefix for all head config-validation failures. */
3278
- const ERROR_PREFIX$8 = "[web] head";
3334
+ const ERROR_PREFIX$9 = "[web] head";
3279
3335
  /** The allowed `twitterCard` literals (also the runtime guard set). */
3280
3336
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3281
3337
  /**
@@ -3302,8 +3358,8 @@ const defaultConfig$3 = { twitterCard: "summary_large_image" };
3302
3358
  * ```
3303
3359
  */
3304
3360
  function validateHeadConfig(config) {
3305
- 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)}.`);
3306
- 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)}.`);
3361
+ 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)}.`);
3362
+ 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)}.`);
3307
3363
  }
3308
3364
  /**
3309
3365
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -3430,14 +3486,16 @@ const JS_ENTRY_CANDIDATES = [
3430
3486
  /**
3431
3487
  * The default bundler runner — adapts the built-in `Bun.build`.
3432
3488
  *
3433
- * @param options - Entry/outdir/minify settings forwarded to `Bun.build`.
3489
+ * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3434
3490
  * @param options.entrypoints - Entry files for this build.
3435
3491
  * @param options.outdir - Output directory.
3436
3492
  * @param options.minify - Whether to minify.
3493
+ * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3494
+ * @param options.target - The bundling target platform.
3437
3495
  * @returns The structural build result.
3438
3496
  * @example
3439
3497
  * ```ts
3440
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true });
3498
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3441
3499
  * ```
3442
3500
  */
3443
3501
  async function defaultRunner(options) {
@@ -3506,7 +3564,9 @@ function normalizeAssetPath(absolutePath, outDir) {
3506
3564
  }
3507
3565
  /**
3508
3566
  * Run one bundler pass for a single asset kind and record the hashed output
3509
- * paths under `state.buildCache` keyed by the original entry basename.
3567
+ * paths under `state.buildCache` keyed by the original entry basename. Lazy
3568
+ * split chunks are emitted to disk but excluded from the recorded manifest
3569
+ * (they must never be embedded as eager `<script>` tags).
3510
3570
  *
3511
3571
  * @param ctx - The phase context (state + log).
3512
3572
  * @param runner - The bundler runner to invoke.
@@ -3525,11 +3585,16 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3525
3585
  const result = await runner({
3526
3586
  entrypoints,
3527
3587
  outdir,
3528
- minify
3588
+ minify,
3589
+ splitting: true,
3590
+ target: "browser"
3529
3591
  });
3530
3592
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3531
3593
  const hashed = {};
3532
- for (const output of result.outputs) hashed[path.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3594
+ for (const output of result.outputs) {
3595
+ if (output.kind === "chunk") continue;
3596
+ hashed[path.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3597
+ }
3533
3598
  ctx.state.buildCache.set(kind, hashed);
3534
3599
  ctx.log.debug("build:bundle", {
3535
3600
  kind,
@@ -3701,10 +3766,34 @@ function createFeedChannel(site, defaultLocale) {
3701
3766
  author: { name: site.author() }
3702
3767
  });
3703
3768
  }
3769
+ /** Matches a root-relative `src`/`href` attribute opening (`="/`), excluding protocol-relative `="//`. */
3770
+ const ROOT_RELATIVE_URL_ATTR = /\b(src|href)="\/(?!\/)/g;
3771
+ /**
3772
+ * Absolutize root-relative `src`/`href` URLs in rendered article HTML against the
3773
+ * site base URL. The content pipeline rewrites co-located images to root-relative
3774
+ * paths (`/<slug>/images/...`) — fine on the site, broken inside a feed, where
3775
+ * readers do not reliably resolve relative URLs. Protocol-relative (`//host/...`)
3776
+ * and already-absolute URLs are left untouched.
3777
+ *
3778
+ * @param html - The rendered article HTML.
3779
+ * @param baseUrl - The absolute site base URL (trailing slashes tolerated).
3780
+ * @returns The HTML with every root-relative URL made absolute.
3781
+ * @example
3782
+ * ```ts
3783
+ * absolutizeContentUrls('<img src="/post/images/a.webp">', "https://blog.dev");
3784
+ * // '<img src="https://blog.dev/post/images/a.webp">'
3785
+ * ```
3786
+ */
3787
+ function absolutizeContentUrls(html, baseUrl) {
3788
+ let base = baseUrl;
3789
+ while (base.endsWith("/")) base = base.slice(0, -1);
3790
+ return html.replaceAll(ROOT_RELATIVE_URL_ATTR, (_match, attribute) => `${attribute}="${base}/`);
3791
+ }
3704
3792
  /**
3705
3793
  * Append one article to the feed and return its canonical GUID. The canonical
3706
3794
  * URL is the article's single stable identity — it is the item's id, guid, and
3707
- * link at once.
3795
+ * link at once. Item content is the rendered HTML with root-relative URLs
3796
+ * absolutized against the site base, so embedded assets resolve in feed readers.
3708
3797
  *
3709
3798
  * @param feed - The feed channel to append to (mutated in place).
3710
3799
  * @param article - The published article to add.
@@ -3723,7 +3812,7 @@ function addArticleItem(feed, article, site) {
3723
3812
  guid: canonicalUrl,
3724
3813
  link: canonicalUrl,
3725
3814
  description: article.frontmatter.description,
3726
- content: article.html,
3815
+ content: absolutizeContentUrls(article.html, site.url()),
3727
3816
  date: new Date(article.frontmatter.date),
3728
3817
  author: [{ name: article.frontmatter.author ?? site.author() }]
3729
3818
  });
@@ -3838,10 +3927,14 @@ async function processImages(ctx, options = {}) {
3838
3927
  //#endregion
3839
3928
  //#region src/plugins/build/phases/locale-redirects.ts
3840
3929
  /**
3841
- * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3930
+ * @file build phase — locale-redirects. For each REQUIRED-`{lang}` route (whose bare,
3931
+ * locale-less path would otherwise 404 — pages writes only `/{locale}/…`), emits a
3842
3932
  * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3843
- * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3844
- * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3933
+ * bare path that points at the default-locale-prefixed URL. OPTIONAL-`{lang:?}`
3934
+ * routes get NO redirect: the default locale is served BARE, so the pages phase
3935
+ * already writes the real content page at the bare path (plus a `/{defaultLocale}/…`
3936
+ * alias) — a redirect there would overwrite content. Deliberately does NOT emit a
3937
+ * Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3845
3938
  * `config.localeRedirects` (false/unset disables).
3846
3939
  *
3847
3940
  * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
@@ -3893,6 +3986,12 @@ function pairRoutes(router) {
3893
3986
  * redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
3894
3987
  * (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
3895
3988
  *
3989
+ * Only a REQUIRED-`{lang}` route produces a job. On an OPTIONAL-`{lang:?}` route the
3990
+ * compiled `toUrl` serves the default locale BARE (`toUrl({ lang: defaultLocale })`
3991
+ * equals the bare URL), so `target === bareUrl` → `null`. That is by design AND the
3992
+ * collision guard: the pages phase writes the default-locale content page at exactly
3993
+ * that bare file, and a redirect would overwrite it.
3994
+ *
3896
3995
  * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3897
3996
  * @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
3898
3997
  * @param defaultLocale - The default locale to redirect bare paths to.
@@ -4637,7 +4736,7 @@ function dataApi(ctx) {
4637
4736
  * ```
4638
4737
  */
4639
4738
  async write(entries, options) {
4640
- const { writeData } = await import("./writer-Dc_lx22j.mjs");
4739
+ const { writeData } = await import("./writer-CaoyORyZ.mjs");
4641
4740
  return writeData(ctx, entries, options);
4642
4741
  },
4643
4742
  /**
@@ -4915,29 +5014,44 @@ async function generateParameterSets(definition, locale, ctx) {
4915
5014
  * locale). The generate context is the spec `{ locale, require, has }`, so a
4916
5015
  * `.generate()` handler pulls sibling APIs the spec way.
4917
5016
  *
5017
+ * Instances are deduplicated by resolved output file: a route whose pattern has no
5018
+ * lang placeholder (or whose `generate()` params omit `lang`) resolves to the SAME
5019
+ * `toFile` path for EVERY locale — without the guard each locale's render races on
5020
+ * one output file and the shipped HTML's locale is nondeterministic. The default
5021
+ * locale is expanded FIRST, so a collapsed route keeps its default-locale instance.
5022
+ *
4918
5023
  * @param definition - The route definition from the manifest.
4919
5024
  * @param locales - Active locale codes from i18n.
5025
+ * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4920
5026
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4921
5027
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4922
- * @returns The flattened list of page instances for this route.
5028
+ * @returns The flattened, file-deduplicated list of page instances for this route.
4923
5029
  * @example
4924
5030
  * ```ts
4925
- * await expandRoute(def, ["en"], byPattern, ctx);
5031
+ * await expandRoute(def, ["en"], "en", byPattern, ctx);
4926
5032
  * ```
4927
5033
  */
4928
- async function expandRoute(definition, locales, byPattern, ctx) {
5034
+ async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4929
5035
  const entry = resolveEntry(byPattern, definition);
4930
5036
  const { name } = entry;
5037
+ const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4931
5038
  const instances = [];
4932
- for (const locale of locales) {
5039
+ const claimedFiles = /* @__PURE__ */ new Set();
5040
+ for (const locale of orderedLocales) {
4933
5041
  const parameterSets = await generateParameterSets(definition, locale, ctx);
4934
- for (const raw of parameterSets) instances.push({
4935
- definition,
4936
- entry,
4937
- name,
4938
- params: raw ?? {},
4939
- locale
4940
- });
5042
+ for (const raw of parameterSets) {
5043
+ const params = raw ?? {};
5044
+ const file = entry.toFile(params);
5045
+ if (claimedFiles.has(file)) continue;
5046
+ claimedFiles.add(file);
5047
+ instances.push({
5048
+ definition,
5049
+ entry,
5050
+ name,
5051
+ params,
5052
+ locale
5053
+ });
5054
+ }
4941
5055
  }
4942
5056
  return instances;
4943
5057
  }
@@ -5247,20 +5361,22 @@ async function prepareShell(ctx) {
5247
5361
  }
5248
5362
  /**
5249
5363
  * Expand every manifest route into its concrete page instances across all locales
5250
- * (delegating per-route expansion to {@link expandRoute}) and flatten the result.
5364
+ * (delegating per-route expansion and per-route output-file deduplication to
5365
+ * {@link expandRoute}) and flatten the result.
5251
5366
  *
5252
5367
  * @param manifest - The route definitions from `router.manifest()`.
5253
5368
  * @param locales - Active locale codes from i18n.
5369
+ * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
5254
5370
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
5255
5371
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
5256
5372
  * @returns The flattened list of page instances to render.
5257
5373
  * @example
5258
5374
  * ```ts
5259
- * const instances = await expandAllInstances(manifest, ["en"], byPattern, ctx);
5375
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
5260
5376
  * ```
5261
5377
  */
5262
- async function expandAllInstances(manifest, locales, byPattern, ctx) {
5263
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern, ctx)))).flat();
5378
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
5379
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
5264
5380
  }
5265
5381
  /**
5266
5382
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5376,7 +5492,7 @@ async function renderPages(ctx, options) {
5376
5492
  const byPattern = makeEntryMap(router);
5377
5493
  if (!reuse) ctx.state.renderCache.clear();
5378
5494
  const shell = await prepareShell(ctx);
5379
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5495
+ 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();
5380
5496
  await writeDataSidecars(ctx, rendered, router.mode());
5381
5497
  ctx.log.debug("build:pages", { count: rendered.length });
5382
5498
  return {
@@ -5451,7 +5567,22 @@ async function expandUrls(definition, entry, locales, ctx) {
5451
5567
  return urls;
5452
5568
  }
5453
5569
  /**
5454
- * Serialize a `<urlset>` sitemap document from a canonical URL set.
5570
+ * XML-escape a value for safe insertion into a text node (`& < > " '`). `&` is
5571
+ * escaped first so already-escaped entities are not double-escaped.
5572
+ *
5573
+ * @param raw - The unsafe string.
5574
+ * @returns The XML-escaped string.
5575
+ * @example
5576
+ * ```ts
5577
+ * escapeXml("https://blog.dev/a&b/"); // "https://blog.dev/a&amp;b/"
5578
+ * ```
5579
+ */
5580
+ function escapeXml(raw) {
5581
+ return raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
5582
+ }
5583
+ /**
5584
+ * Serialize a `<urlset>` sitemap document from a canonical URL set. Each `<loc>`
5585
+ * value is XML-escaped so slugs containing `&`/`<`/`>` cannot break the document.
5455
5586
  *
5456
5587
  * @param urls - The canonical (absolute) URLs.
5457
5588
  * @returns The serialized sitemap XML.
@@ -5461,7 +5592,7 @@ async function expandUrls(definition, entry, locales, ctx) {
5461
5592
  * ```
5462
5593
  */
5463
5594
  function serializeSitemap(urls) {
5464
- 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`;
5595
+ 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`;
5465
5596
  }
5466
5597
  /**
5467
5598
  * Index the compiled router entries by their URL pattern, so each manifest
@@ -5567,7 +5698,8 @@ async function generateSitemap(ctx) {
5567
5698
  const locales = ctx.require(i18nPlugin).locales();
5568
5699
  const router = ctx.require(routerPlugin);
5569
5700
  const byPattern = indexRoutesByPattern(router);
5570
- const urls = (await collectRelativeUrls(router.manifest(), byPattern, locales, ctx)).map((relative) => site.canonical(relative));
5701
+ const relativeUrls = await collectRelativeUrls(router.manifest(), byPattern, locales, ctx);
5702
+ const urls = [...new Set(relativeUrls)].map((relative) => site.canonical(relative));
5571
5703
  const xml = serializeSitemap(urls);
5572
5704
  const robots = buildRobotsTxt(site);
5573
5705
  await writeSitemapFiles(ctx.config.outDir, xml, robots);
@@ -5584,6 +5716,8 @@ async function generateSitemap(ctx) {
5584
5716
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5585
5717
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5586
5718
  */
5719
+ /** Error prefix for build pipeline runtime failures (spec/11 Part-3). */
5720
+ const ERROR_PREFIX$8 = "[web] build";
5587
5721
  /** Matches a Markdown source path (a content edit). */
5588
5722
  const MARKDOWN_PATH = /\.md$/;
5589
5723
  /** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
@@ -5619,6 +5753,46 @@ function planIncrementalRebuild(changed) {
5619
5753
  };
5620
5754
  }
5621
5755
  /**
5756
+ * Test whether a resolved path sits STRICTLY inside a resolved base directory —
5757
+ * equality does not count (the base itself is never "inside" itself).
5758
+ *
5759
+ * @param resolved - The resolved absolute candidate path.
5760
+ * @param baseResolved - The resolved absolute base directory.
5761
+ * @returns `true` when `resolved` is nested beneath `baseResolved`.
5762
+ * @example
5763
+ * ```ts
5764
+ * isStrictlyInside("/app/dist", "/app"); // true — but isStrictlyInside("/app", "/app") is false
5765
+ * ```
5766
+ */
5767
+ function isStrictlyInside(resolved, baseResolved) {
5768
+ return resolved !== baseResolved && resolved.startsWith(baseResolved + path.sep);
5769
+ }
5770
+ /**
5771
+ * Assert that `outDir` is a SAFE target for the clean phase's recursive force-delete,
5772
+ * defending against a misconfiguration (`outDir: "/"`, `"."`, `"~"`, a `..` escape)
5773
+ * that would otherwise wipe the filesystem root, the home directory, or the project
5774
+ * itself. Mirrors the deploy plugin's `assertWithinRoot` posture, tightened for
5775
+ * deletion: a target is safe only when it sits STRICTLY inside the project root
5776
+ * (never the root itself) or strictly inside the OS temp directory (a disposable
5777
+ * area, used by preview/test builds) — and is never the home directory.
5778
+ *
5779
+ * @param outDir - The configured output directory (relative or absolute).
5780
+ * @param root - The absolute project root relative paths resolve against.
5781
+ * @returns The resolved absolute output directory.
5782
+ * @throws {Error} `[web] build.outDir` when the resolved target is unsafe to delete.
5783
+ * @example
5784
+ * ```ts
5785
+ * assertSafeCleanTarget("./dist", process.cwd()); // "<cwd>/dist"
5786
+ * ```
5787
+ */
5788
+ function assertSafeCleanTarget(outDir, root) {
5789
+ const resolved = path.isAbsolute(outDir) ? path.resolve(outDir) : path.resolve(root, outDir);
5790
+ const rootResolved = path.resolve(root);
5791
+ const isHome = resolved === path.resolve(homedir());
5792
+ if ((isStrictlyInside(resolved, rootResolved) || isStrictlyInside(resolved, path.resolve(tmpdir()))) && !isHome) return resolved;
5793
+ 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".`);
5794
+ }
5795
+ /**
5622
5796
  * The static ordered list of pipeline phase names.
5623
5797
  *
5624
5798
  * @example
@@ -5668,17 +5842,23 @@ async function withPhase(ctx, phase, work) {
5668
5842
  }
5669
5843
  /**
5670
5844
  * Reset the per-run state (manifest, buildCache, runId) and assign a fresh runId.
5845
+ * A clean run (no `skipClean`) also drops the OG image hash cache: the outDir wipe
5846
+ * deletes every `og/<slug>.png` the cache indexes, so honoring those warm entries
5847
+ * would skip rendering files that no longer exist. A `skipClean` (dev) run keeps
5848
+ * the cache — its PNGs survive on disk.
5671
5849
  *
5672
5850
  * @param ctx - The phase context whose `state` is reset.
5851
+ * @param options - The run options (only `skipClean` is consulted).
5673
5852
  * @example
5674
5853
  * ```ts
5675
- * resetRun(ctx);
5854
+ * resetRun(ctx, options);
5676
5855
  * ```
5677
5856
  */
5678
- function resetRun(ctx) {
5857
+ function resetRun(ctx, options) {
5679
5858
  ctx.state.manifest = null;
5680
5859
  ctx.state.buildCache = /* @__PURE__ */ new Map();
5681
5860
  ctx.state.runId = `${Date.now()}-${randomUUID()}`;
5861
+ if (!options?.skipClean) ctx.state.ogImageHashCache.clear();
5682
5862
  }
5683
5863
  /**
5684
5864
  * Report each rejected outcome from a settled output batch as a `build:outputs`
@@ -5740,7 +5920,7 @@ async function runOutputs(ctx) {
5740
5920
  */
5741
5921
  async function runPipeline(ctx, options) {
5742
5922
  const started = Date.now();
5743
- resetRun(ctx);
5923
+ resetRun(ctx, options);
5744
5924
  const outDir = options?.outDir ?? ctx.config.outDir;
5745
5925
  const phaseContext = {
5746
5926
  ...ctx,
@@ -5751,10 +5931,13 @@ async function runPipeline(ctx, options) {
5751
5931
  }
5752
5932
  };
5753
5933
  const plan = planIncrementalRebuild(options?.changed);
5754
- if (!options?.skipClean) await rm(outDir, {
5755
- recursive: true,
5756
- force: true
5757
- });
5934
+ if (!options?.skipClean) {
5935
+ assertSafeCleanTarget(outDir, process.cwd());
5936
+ await rm(outDir, {
5937
+ recursive: true,
5938
+ force: true
5939
+ });
5940
+ }
5758
5941
  await mkdir(outDir, { recursive: true });
5759
5942
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5760
5943
  await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
@@ -10050,6 +10233,23 @@ function isInternalLink(url) {
10050
10233
  return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
10051
10234
  }
10052
10235
  /**
10236
+ * The navigable path of a URL or Location: pathname plus query string. The query
10237
+ * is part of page identity (the kernel's `currentUrl` is pathname + search), so
10238
+ * same-page checks, history entries, fetches, and scroll keys must all carry it —
10239
+ * comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
10240
+ * and the History fallback would drop the query from the address bar.
10241
+ *
10242
+ * @param target - The URL or Location to read.
10243
+ * @param target.pathname - The path component.
10244
+ * @param target.search - The query-string component (`""` when absent).
10245
+ * @returns The pathname + search string.
10246
+ * @example
10247
+ * pathWithSearch(new URL("https://x.dev/search?q=a")); // "/search?q=a"
10248
+ */
10249
+ function pathWithSearch(target) {
10250
+ return target.pathname + target.search;
10251
+ }
10252
+ /**
10053
10253
  * Save the current scroll position keyed by path (best-effort; ignores storage errors).
10054
10254
  *
10055
10255
  * @param path - The path to key the scroll position under.
@@ -10078,19 +10278,27 @@ function restoreScrollPosition(path) {
10078
10278
  * Fetch a page and hand its HTML to the handlers; on any error fall back to a
10079
10279
  * full browser navigation (`location.href = pathname`).
10080
10280
  *
10281
+ * When `signal` aborts (this navigation was superseded by a newer one) the
10282
+ * fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
10283
+ * reload — the live navigation owns the document from that point on.
10284
+ *
10081
10285
  * @param pathname - The destination pathname.
10082
10286
  * @param handlers - The navigation lifecycle callbacks.
10287
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10083
10288
  * @returns A promise that resolves once the swap (or fallback) is dispatched.
10084
10289
  * @example
10085
- * await performNavigation("/about", handlers);
10290
+ * await performNavigation("/about", handlers, navEvent.signal);
10086
10291
  */
10087
- async function performNavigation(pathname, handlers) {
10292
+ async function performNavigation(pathname, handlers, signal) {
10088
10293
  handlers.onStart(pathname);
10089
10294
  try {
10090
- const response = await fetch(pathname);
10295
+ const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10091
10296
  if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10092
- handlers.onEnd(await response.text(), pathname);
10297
+ const html = await response.text();
10298
+ if (signal?.aborted) return;
10299
+ handlers.onEnd(html, pathname);
10093
10300
  } catch {
10301
+ if (signal?.aborted) return;
10094
10302
  handlers.onError();
10095
10303
  location.href = pathname;
10096
10304
  }
@@ -10129,23 +10337,29 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
10129
10337
  * inside the same transition frame (after the DOM mutation) so component
10130
10338
  * re-mounting is captured by the transition snapshot.
10131
10339
  *
10340
+ * Returns whether the swap was dispatched: `false` when either document lacks
10341
+ * the `swapSelector` region, so the caller can fall back to a full navigation
10342
+ * instead of finishing the SPA nav against an un-swapped body.
10343
+ *
10132
10344
  * @param doc - The fetched document (DOMParser-parsed) holding the new region.
10133
10345
  * @param swapSelector - CSS selector for the region to replace.
10134
10346
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10135
10347
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10136
10348
  * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10137
10349
  * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10350
+ * @returns `true` when the swap was dispatched, `false` when either document lacks the region.
10138
10351
  * @example
10139
10352
  * swapRegion(doc, "main > section", false, () => mountNew());
10140
10353
  */
10141
10354
  function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10142
10355
  const newContent = doc.querySelector(swapSelector);
10143
10356
  const currentContent = document.querySelector(swapSelector);
10144
- if (!newContent || !currentContent) return;
10357
+ if (!newContent || !currentContent) return false;
10145
10358
  runSwap(() => {
10146
10359
  currentContent.replaceWith(newContent);
10147
10360
  onSwapped();
10148
10361
  }, viewTransitions, beforeCapture);
10362
+ return true;
10149
10363
  }
10150
10364
  /**
10151
10365
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10179,7 +10393,20 @@ function resolveClickTarget(event) {
10179
10393
  * @example
10180
10394
  * const dispose = attachHistoryFallback(handlers);
10181
10395
  */
10182
- function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
10396
+ function attachHistoryFallback(handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
10397
+ let controller;
10398
+ /**
10399
+ * Supersede the in-flight navigation (if any) and mint the next one's abort signal.
10400
+ *
10401
+ * @returns The fresh navigation's abort signal.
10402
+ * @example
10403
+ * const signal = supersede();
10404
+ */
10405
+ const supersede = () => {
10406
+ controller?.abort();
10407
+ controller = new AbortController();
10408
+ return controller.signal;
10409
+ };
10183
10410
  /**
10184
10411
  * Intercept an internal-link click and run a History-API navigation.
10185
10412
  *
@@ -10190,17 +10417,18 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10190
10417
  const onClick = (event) => {
10191
10418
  const url = resolveClickTarget(event);
10192
10419
  if (!url) return;
10420
+ if (url.pathname === location.pathname && url.hash) return;
10193
10421
  event.preventDefault();
10194
- if (url.pathname === location.pathname) {
10422
+ if (pathWithSearch(url) === pathWithSearch(location)) {
10195
10423
  window.scrollTo({
10196
10424
  top: 0,
10197
10425
  behavior: "smooth"
10198
10426
  });
10199
10427
  return;
10200
10428
  }
10201
- saveScrollPosition(location.pathname);
10202
- history.pushState({ scrollY: 0 }, "", url.pathname);
10203
- navigate(url.pathname).catch(() => {});
10429
+ saveScrollPosition(pathWithSearch(location));
10430
+ history.pushState({ scrollY: 0 }, "", pathWithSearch(url));
10431
+ navigate(pathWithSearch(url), true, supersede()).catch(() => {});
10204
10432
  };
10205
10433
  /**
10206
10434
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10209,7 +10437,11 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10209
10437
  * globalThis.addEventListener("popstate", onPopState);
10210
10438
  */
10211
10439
  const onPopState = () => {
10212
- navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10440
+ const path = pathWithSearch(location);
10441
+ const signal = supersede();
10442
+ navigate(path, false, signal).then(() => {
10443
+ if (!signal.aborted) restoreScrollPosition(path);
10444
+ }).catch(() => {});
10213
10445
  };
10214
10446
  document.addEventListener("click", onClick);
10215
10447
  globalThis.addEventListener("popstate", onPopState);
@@ -10228,7 +10460,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10228
10460
  * @example
10229
10461
  * const dispose = attachNavigationApi(navigation, handlers);
10230
10462
  */
10231
- function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
10463
+ function attachNavigationApi(navigation, handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
10232
10464
  /**
10233
10465
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
10234
10466
  *
@@ -10240,7 +10472,7 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10240
10472
  const url = new URL(navEvent.destination.url);
10241
10473
  if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
10242
10474
  if (!isInternalLink(url)) return;
10243
- if (url.pathname === location.pathname) {
10475
+ if (pathWithSearch(url) === pathWithSearch(location)) {
10244
10476
  navEvent.intercept({ handler: () => {
10245
10477
  window.scrollTo({
10246
10478
  top: 0,
@@ -10254,9 +10486,9 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10254
10486
  scroll: "manual",
10255
10487
  handler: async () => {
10256
10488
  if (navEvent.navigationType === "traverse") {
10257
- await navigate(url.pathname, false);
10258
- navEvent.scroll();
10259
- } else await navigate(url.pathname);
10489
+ await navigate(pathWithSearch(url), false, navEvent.signal);
10490
+ if (!navEvent.signal.aborted) navEvent.scroll();
10491
+ } else await navigate(pathWithSearch(url), true, navEvent.signal);
10260
10492
  }
10261
10493
  });
10262
10494
  };
@@ -10442,6 +10674,11 @@ function createSpaKernel(state, config, emit, deps) {
10442
10674
  };
10443
10675
  /**
10444
10676
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10677
+ * When the region cannot be swapped (either document lacks the swap selector)
10678
+ * the SPA nav cannot complete — the head is already synced and the islands torn
10679
+ * down, so finishing would leave the OLD body under a NEW URL with a `spa:navigated`
10680
+ * claiming success. Fall back to a full browser navigation instead (mirroring
10681
+ * {@link performNavigation}'s fetch-error fallback).
10445
10682
  *
10446
10683
  * @param html - The fetched page HTML.
10447
10684
  * @param pathname - The destination pathname.
@@ -10452,10 +10689,14 @@ function createSpaKernel(state, config, emit, deps) {
10452
10689
  const doc = new DOMParser().parseFromString(html, "text/html");
10453
10690
  syncHead(deps.head, doc);
10454
10691
  unmountPageSpecific(state, emit);
10455
- swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10692
+ if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10456
10693
  scanAndMount(state, emit, resolved.swapSelector);
10457
10694
  notifyNavEnd(state);
10458
- }, applyPendingScroll);
10695
+ }, applyPendingScroll)) {
10696
+ handleError();
10697
+ location.href = pathname;
10698
+ return;
10699
+ }
10459
10700
  state.currentUrl = pathname;
10460
10701
  progress?.done();
10461
10702
  emit("spa:navigated", { url: pathname });
@@ -10535,13 +10776,16 @@ function createSpaKernel(state, config, emit, deps) {
10535
10776
  *
10536
10777
  * @param pathname - The destination pathname (recorded as the new current URL).
10537
10778
  * @param resolvedRender - The inputs produced by {@link resolveDataRender}.
10779
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10538
10780
  * @example
10539
10781
  * await commitDataRender("/en/world/", resolved);
10540
10782
  */
10541
- const commitDataRender = async (pathname, resolvedRender) => {
10783
+ const commitDataRender = async (pathname, resolvedRender, signal) => {
10784
+ if (signal?.aborted) return;
10542
10785
  const { route, vnode, routeContext, region } = resolvedRender;
10543
10786
  handleStart(pathname);
10544
10787
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10788
+ if (signal?.aborted) return;
10545
10789
  syncDataHead(route, routeContext);
10546
10790
  unmountPageSpecific(state, emit);
10547
10791
  /**
@@ -10573,15 +10817,16 @@ function createSpaKernel(state, config, emit, deps) {
10573
10817
  * to HTML-over-fetch.
10574
10818
  *
10575
10819
  * @param pathname - The destination pathname (search stripped for matching).
10820
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10576
10821
  * @returns `true` if the route was rendered from its data, else `false`.
10577
10822
  * @example
10578
10823
  * if (await tryDataRender("/en/world/")) return;
10579
10824
  */
10580
- const tryDataRender = async (pathname) => {
10825
+ const tryDataRender = async (pathname, signal) => {
10581
10826
  try {
10582
10827
  const resolvedRender = await resolveDataRender(pathname);
10583
10828
  if (resolvedRender === false) return false;
10584
- await commitDataRender(pathname, resolvedRender);
10829
+ await commitDataRender(pathname, resolvedRender, signal);
10585
10830
  return true;
10586
10831
  } catch {
10587
10832
  progress?.done();
@@ -10597,14 +10842,17 @@ function createSpaKernel(state, config, emit, deps) {
10597
10842
  * @param pathname - The destination pathname.
10598
10843
  * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10599
10844
  * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10845
+ * @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
10846
+ * a superseded navigation never applies its swap (no stale last-write-wins).
10600
10847
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10601
10848
  * @example
10602
10849
  * await navigate("/en/world/");
10603
10850
  */
10604
- const navigate = async (pathname, scrollToTop = true) => {
10851
+ const navigate = async (pathname, scrollToTop = true, signal) => {
10605
10852
  pendingScrollToTop = scrollToTop;
10606
- if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10607
- await performNavigation(pathname, handlers);
10853
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
10854
+ if (signal?.aborted) return;
10855
+ await performNavigation(pathname, handlers, signal);
10608
10856
  };
10609
10857
  return {
10610
10858
  /**
@@ -11205,9 +11453,12 @@ function defaultRehypePlugins() {
11205
11453
  * Clones the library default and additively allowlists the markup our custom
11206
11454
  * transforms emit: `class` values (`pull-quote`, `section-divider`,
11207
11455
  * `section-divider-ornament`) on `aside`/`div`/`span`, and the `loading`
11208
- * attribute on `img`. `class`/`className`/`style` are allowlisted globally (`*`,
11209
- * i.e. on every element) not just on `pre`/`code`/`span` so Shiki's inline
11210
- * token colors survive the sanitize pass.
11456
+ * attribute on `img`. `class`/`className` are allowlisted globally (`*`, i.e.
11457
+ * on every element) so Shiki's class hooks survive the sanitize pass. `style`
11458
+ * is deliberately NOT global CSS values are not sanitized, so a global
11459
+ * `style` allowlist would let untrusted content run overlay/exfiltration
11460
+ * styling; it is allowed only on `pre`/`code`, where Shiki places its
11461
+ * block-level theme background/foreground.
11211
11462
  *
11212
11463
  * @returns The extended, security-hardened sanitize schema.
11213
11464
  * @example
@@ -11238,8 +11489,7 @@ function buildSanitizeSchema() {
11238
11489
  "*": [
11239
11490
  ...baseAttributes["*"] ?? [],
11240
11491
  "className",
11241
- "class",
11242
- "style"
11492
+ "class"
11243
11493
  ],
11244
11494
  aside: [
11245
11495
  ...baseAttributes.aside ?? [],