@moku-labs/web 1.6.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/dist/browser.mjs +169 -48
- package/dist/{convention-krwh7Y6Q.cjs → convention-BpDfzX7e.cjs} +28 -4
- package/dist/{convention-CepUwWmT.mjs → convention-Dp650o3y.mjs} +28 -4
- package/dist/index.cjs +677 -193
- package/dist/index.d.cts +30 -4
- package/dist/index.d.mts +30 -4
- package/dist/index.mjs +677 -193
- package/dist/{writer-Dc_lx22j.mjs → writer-CaoyORyZ.mjs} +1 -1
- package/dist/{writer-DV5hWB2i.cjs → writer-JdhX1Wld.cjs} +1 -1
- package/package.json +10 -5
package/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-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
212
|
-
if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$
|
|
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$
|
|
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$
|
|
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$
|
|
919
|
-
if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$
|
|
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$
|
|
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$
|
|
1700
|
-
if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$
|
|
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$
|
|
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$
|
|
2100
|
-
if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$
|
|
2101
|
-
if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$
|
|
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$
|
|
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
|
-
*
|
|
2152
|
-
*
|
|
2153
|
-
*
|
|
2154
|
-
* `{lang:?}`
|
|
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
|
-
* @
|
|
2191
|
+
* @param encodeValue - Encoder applied to each substituted param value.
|
|
2192
|
+
* @returns The resolved relative path string.
|
|
2164
2193
|
* @example
|
|
2165
2194
|
* ```ts
|
|
2166
|
-
*
|
|
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
|
|
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 (
|
|
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 =
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
3003
|
-
* route's canonical URL for that locale,
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
3306
|
-
if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$
|
|
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
|
|
@@ -3417,7 +3473,9 @@ const headPlugin = createPlugin$1("head", {
|
|
|
3417
3473
|
//#region src/plugins/build/phases/bundle.ts
|
|
3418
3474
|
/**
|
|
3419
3475
|
* @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
|
|
3420
|
-
* outDir (honoring `config.minify`)
|
|
3476
|
+
* outDir (honoring `config.minify`) with content-hashed output naming; caches the
|
|
3477
|
+
* fingerprinted asset paths for the pages phase and the complete output list for
|
|
3478
|
+
* the cache-headers phase.
|
|
3421
3479
|
*/
|
|
3422
3480
|
/** Conventional CSS entry candidates (project-relative). */
|
|
3423
3481
|
const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
|
|
@@ -3428,16 +3486,38 @@ const JS_ENTRY_CANDIDATES = [
|
|
|
3428
3486
|
"src/main.ts"
|
|
3429
3487
|
];
|
|
3430
3488
|
/**
|
|
3489
|
+
* `Bun.build` output naming with a content hash in EVERY filename (entry points
|
|
3490
|
+
* included — Bun's default only hashes chunks/assets). A bundle's URL therefore
|
|
3491
|
+
* changes whenever its bytes change, which is what lets the cache-headers phase
|
|
3492
|
+
* mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
|
|
3493
|
+
* that changes the code ships a NEW URL instead of fighting a stale cached copy.
|
|
3494
|
+
* Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
|
|
3495
|
+
* names flow through with no app-side changes (hardcoded asset URLs must move to
|
|
3496
|
+
* the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
|
|
3497
|
+
* `chunk-` prefix (chunks were already hash-only named).
|
|
3498
|
+
*/
|
|
3499
|
+
const FINGERPRINT_NAMING = {
|
|
3500
|
+
entry: "[dir]/[name]-[hash].[ext]",
|
|
3501
|
+
chunk: "chunk-[hash].[ext]",
|
|
3502
|
+
asset: "[name]-[hash].[ext]"
|
|
3503
|
+
};
|
|
3504
|
+
/**
|
|
3431
3505
|
* The default bundler runner — adapts the built-in `Bun.build`.
|
|
3432
3506
|
*
|
|
3433
|
-
* @param options - Entry/outdir/minify settings forwarded to `Bun.build`.
|
|
3507
|
+
* @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
|
|
3434
3508
|
* @param options.entrypoints - Entry files for this build.
|
|
3435
3509
|
* @param options.outdir - Output directory.
|
|
3436
3510
|
* @param options.minify - Whether to minify.
|
|
3511
|
+
* @param options.splitting - Whether to split dynamic imports into lazy chunks.
|
|
3512
|
+
* @param options.target - The bundling target platform.
|
|
3513
|
+
* @param options.naming - Output naming templates (content-hashed filenames).
|
|
3514
|
+
* @param options.naming.entry - Naming template for entry-point outputs.
|
|
3515
|
+
* @param options.naming.chunk - Naming template for lazy split chunks.
|
|
3516
|
+
* @param options.naming.asset - Naming template for additional emitted assets.
|
|
3437
3517
|
* @returns The structural build result.
|
|
3438
3518
|
* @example
|
|
3439
3519
|
* ```ts
|
|
3440
|
-
* await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true });
|
|
3520
|
+
* await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
|
|
3441
3521
|
* ```
|
|
3442
3522
|
*/
|
|
3443
3523
|
async function defaultRunner(options) {
|
|
@@ -3506,7 +3586,9 @@ function normalizeAssetPath(absolutePath, outDir) {
|
|
|
3506
3586
|
}
|
|
3507
3587
|
/**
|
|
3508
3588
|
* 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.
|
|
3589
|
+
* paths under `state.buildCache` keyed by the original entry basename. Lazy
|
|
3590
|
+
* split chunks are emitted to disk but excluded from the recorded manifest
|
|
3591
|
+
* (they must never be embedded as eager `<script>` tags).
|
|
3510
3592
|
*
|
|
3511
3593
|
* @param ctx - The phase context (state + log).
|
|
3512
3594
|
* @param runner - The bundler runner to invoke.
|
|
@@ -3525,12 +3607,19 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3525
3607
|
const result = await runner({
|
|
3526
3608
|
entrypoints,
|
|
3527
3609
|
outdir,
|
|
3528
|
-
minify
|
|
3610
|
+
minify,
|
|
3611
|
+
splitting: true,
|
|
3612
|
+
target: "browser",
|
|
3613
|
+
naming: FINGERPRINT_NAMING
|
|
3529
3614
|
});
|
|
3530
3615
|
if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
|
|
3531
3616
|
const hashed = {};
|
|
3532
|
-
for (const output of result.outputs)
|
|
3617
|
+
for (const output of result.outputs) {
|
|
3618
|
+
if (output.kind === "chunk") continue;
|
|
3619
|
+
hashed[path.basename(output.path)] = normalizeAssetPath(output.path, outDir);
|
|
3620
|
+
}
|
|
3533
3621
|
ctx.state.buildCache.set(kind, hashed);
|
|
3622
|
+
ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
|
|
3534
3623
|
ctx.log.debug("build:bundle", {
|
|
3535
3624
|
kind,
|
|
3536
3625
|
count: result.outputs.length
|
|
@@ -3562,6 +3651,269 @@ async function bundle(ctx, options = {}) {
|
|
|
3562
3651
|
await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
|
|
3563
3652
|
}
|
|
3564
3653
|
//#endregion
|
|
3654
|
+
//#region src/plugins/build/phases/asset-tags.ts
|
|
3655
|
+
/** Template placeholder for the injected asset tags (stylesheets + scripts). */
|
|
3656
|
+
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
3657
|
+
/** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
|
|
3658
|
+
const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
|
|
3659
|
+
/** Template placeholder for the injected `<script>` tags ONLY. */
|
|
3660
|
+
const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
|
|
3661
|
+
/**
|
|
3662
|
+
* Read the bundle phase's fingerprinted asset manifest for one kind from
|
|
3663
|
+
* `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
|
|
3664
|
+
* reads at call sites).
|
|
3665
|
+
*
|
|
3666
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3667
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
3668
|
+
* @returns The fingerprinted-path manifest entry, or an empty object when absent.
|
|
3669
|
+
* @example
|
|
3670
|
+
* ```ts
|
|
3671
|
+
* readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
|
|
3672
|
+
* ```
|
|
3673
|
+
*/
|
|
3674
|
+
function readManifest(ctx, kind) {
|
|
3675
|
+
const entry = ctx.state.buildCache.get(kind);
|
|
3676
|
+
return entry && typeof entry === "object" ? entry : {};
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
|
|
3680
|
+
* chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
|
|
3681
|
+
* {@link readManifest} this includes chunks — it feeds the cache-headers phase's
|
|
3682
|
+
* per-file immutable rules, where every fingerprinted file counts, not just the
|
|
3683
|
+
* eagerly embedded entries.
|
|
3684
|
+
*
|
|
3685
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3686
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
3687
|
+
* @returns The publish-root-relative output paths, or an empty array when absent.
|
|
3688
|
+
* @example
|
|
3689
|
+
* ```ts
|
|
3690
|
+
* readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
|
|
3691
|
+
* ```
|
|
3692
|
+
*/
|
|
3693
|
+
function readBundleOutputs(ctx, kind) {
|
|
3694
|
+
const entry = ctx.state.buildCache.get(`${kind}:outputs`);
|
|
3695
|
+
return Array.isArray(entry) ? entry : [];
|
|
3696
|
+
}
|
|
3697
|
+
/**
|
|
3698
|
+
* Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
|
|
3699
|
+
*
|
|
3700
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3701
|
+
* @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
|
|
3702
|
+
* @example
|
|
3703
|
+
* ```ts
|
|
3704
|
+
* buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
|
|
3705
|
+
* ```
|
|
3706
|
+
*/
|
|
3707
|
+
function buildCssTags(ctx) {
|
|
3708
|
+
return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Render the module `<script>` tags for the fingerprinted JS manifest.
|
|
3712
|
+
*
|
|
3713
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3714
|
+
* @returns The concatenated `<script type="module">` tags (possibly `""`).
|
|
3715
|
+
* @example
|
|
3716
|
+
* ```ts
|
|
3717
|
+
* buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
|
|
3718
|
+
* ```
|
|
3719
|
+
*/
|
|
3720
|
+
function buildJsTags(ctx) {
|
|
3721
|
+
return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* Build the asset tag block from the fingerprinted manifests — both kinds by
|
|
3725
|
+
* default, or a single kind for the split `<!--moku:assets:css/js-->`
|
|
3726
|
+
* placeholders. Returns an empty string when `config.injectAssets === false`.
|
|
3727
|
+
* Asset paths are emitted as absolute (`/`-rooted) URLs.
|
|
3728
|
+
*
|
|
3729
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
3730
|
+
* @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
|
|
3731
|
+
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
3732
|
+
* @example
|
|
3733
|
+
* ```ts
|
|
3734
|
+
* buildAssetTags(ctx); // <link …><script …><\/script>
|
|
3735
|
+
* buildAssetTags(ctx, "css"); // <link …> only
|
|
3736
|
+
* ```
|
|
3737
|
+
*/
|
|
3738
|
+
function buildAssetTags(ctx, kind) {
|
|
3739
|
+
if (ctx.config.injectAssets === false) return "";
|
|
3740
|
+
if (kind === "css") return buildCssTags(ctx);
|
|
3741
|
+
if (kind === "js") return buildJsTags(ctx);
|
|
3742
|
+
return buildCssTags(ctx) + buildJsTags(ctx);
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
|
|
3746
|
+
* document: the combined block, the CSS-only block, and the JS-only block. A
|
|
3747
|
+
* document without placeholders passes through byte-identical — substitution is
|
|
3748
|
+
* strictly opt-in for app-owned pages (the not-found page).
|
|
3749
|
+
*
|
|
3750
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
3751
|
+
* @param html - The HTML document to substitute placeholders in.
|
|
3752
|
+
* @returns The document with all asset placeholders replaced.
|
|
3753
|
+
* @example
|
|
3754
|
+
* ```ts
|
|
3755
|
+
* substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
|
|
3756
|
+
* ```
|
|
3757
|
+
*/
|
|
3758
|
+
function substituteAssetPlaceholders(ctx, html) {
|
|
3759
|
+
return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
3763
|
+
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
3764
|
+
* the source directory does not exist.
|
|
3765
|
+
*
|
|
3766
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3767
|
+
* @returns The copy result, or `null` when the public directory is absent.
|
|
3768
|
+
* @example
|
|
3769
|
+
* ```ts
|
|
3770
|
+
* const result = await copyPublic(ctx);
|
|
3771
|
+
* ```
|
|
3772
|
+
*/
|
|
3773
|
+
async function copyPublic(ctx) {
|
|
3774
|
+
const from = ctx.config.publicDir ?? "public";
|
|
3775
|
+
if (!existsSync(from)) {
|
|
3776
|
+
ctx.log.debug("build:public", {
|
|
3777
|
+
skipped: true,
|
|
3778
|
+
from
|
|
3779
|
+
});
|
|
3780
|
+
return null;
|
|
3781
|
+
}
|
|
3782
|
+
await cp(from, ctx.config.outDir, { recursive: true });
|
|
3783
|
+
ctx.log.debug("build:public", {
|
|
3784
|
+
from,
|
|
3785
|
+
dest: ctx.config.outDir
|
|
3786
|
+
});
|
|
3787
|
+
return {
|
|
3788
|
+
from: path.normalize(from),
|
|
3789
|
+
copied: 1
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
//#endregion
|
|
3793
|
+
//#region src/plugins/build/phases/cache-headers.ts
|
|
3794
|
+
/**
|
|
3795
|
+
* @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
|
|
3796
|
+
* header rules) so the CDN/browser cache can never serve a stale file: every
|
|
3797
|
+
* fingerprinted bundle gets a per-file immutable rule (its URL changes with its
|
|
3798
|
+
* content, so caching it forever is safe), and every OTHER URL — pages, content
|
|
3799
|
+
* images, feeds, data sidecars: stable URLs whose bytes may change between
|
|
3800
|
+
* deploys — gets a catch-all revalidation rule (an unchanged file still answers
|
|
3801
|
+
* `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
|
|
3802
|
+
* picked up immediately). The app's own `<publicDir>/_headers` rules are appended
|
|
3803
|
+
* AFTER the generated ones so the app can override them. Gated by
|
|
3804
|
+
* `config.cacheHeaders` (`false` disables; default on).
|
|
3805
|
+
*/
|
|
3806
|
+
/**
|
|
3807
|
+
* `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
|
|
3808
|
+
* the bytes behind a given URL can never change — cache them for a year, immutably.
|
|
3809
|
+
*/
|
|
3810
|
+
const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
|
|
3811
|
+
/**
|
|
3812
|
+
* `Cache-Control` for everything else (stable URLs): always revalidate with the
|
|
3813
|
+
* origin. Unchanged files still serve from cache via a `304` ETag round-trip;
|
|
3814
|
+
* changed files are fetched fresh — never stale, still cheap.
|
|
3815
|
+
*/
|
|
3816
|
+
const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
|
|
3817
|
+
/**
|
|
3818
|
+
* Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
|
|
3819
|
+
* a site whose bundle count pushes past the cap needs a warning, not silence.
|
|
3820
|
+
*/
|
|
3821
|
+
const CLOUDFLARE_RULE_LIMIT = 100;
|
|
3822
|
+
/**
|
|
3823
|
+
* Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
|
|
3824
|
+
* object — `false` never reaches here; the pipeline gates the phase off).
|
|
3825
|
+
*
|
|
3826
|
+
* @param cacheHeaders - The `config.cacheHeaders` value.
|
|
3827
|
+
* @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
|
|
3828
|
+
* @example
|
|
3829
|
+
* ```ts
|
|
3830
|
+
* resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
|
|
3831
|
+
* ```
|
|
3832
|
+
*/
|
|
3833
|
+
function resolvePolicy(cacheHeaders) {
|
|
3834
|
+
const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
|
|
3835
|
+
return {
|
|
3836
|
+
assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
|
|
3837
|
+
pages: policy.pages ?? DEFAULT_PAGES_CACHE
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
|
|
3842
|
+
* one immutable rule per fingerprinted bundle file. Cloudflare applies every
|
|
3843
|
+
* matching rule and comma-joins duplicate headers (it does NOT override), so each
|
|
3844
|
+
* per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
|
|
3845
|
+
* before attaching its own — otherwise a bundle would be served with two joined,
|
|
3846
|
+
* contradictory `Cache-Control` values.
|
|
3847
|
+
*
|
|
3848
|
+
* @param files - The fingerprinted bundle web paths (publish-root-relative).
|
|
3849
|
+
* @param policy - The resolved `Cache-Control` values.
|
|
3850
|
+
* @param policy.assets - The value for fingerprinted bundles.
|
|
3851
|
+
* @param policy.pages - The catch-all value for everything else.
|
|
3852
|
+
* @returns The generated rule blocks, in emission order.
|
|
3853
|
+
* @example
|
|
3854
|
+
* ```ts
|
|
3855
|
+
* composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
|
|
3856
|
+
* ```
|
|
3857
|
+
*/
|
|
3858
|
+
function composeRules(files, policy) {
|
|
3859
|
+
return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
|
|
3860
|
+
}
|
|
3861
|
+
/**
|
|
3862
|
+
* Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
|
|
3863
|
+
* phase may have placed in outDir — composing from the source keeps this phase
|
|
3864
|
+
* idempotent and independent of phase ordering). Returns `""` when absent.
|
|
3865
|
+
*
|
|
3866
|
+
* @param publicDir - The configured public directory (or the default).
|
|
3867
|
+
* @returns The app's `_headers` content, or `""` when the file does not exist.
|
|
3868
|
+
* @example
|
|
3869
|
+
* ```ts
|
|
3870
|
+
* const appRules = await readAppHeaders("public");
|
|
3871
|
+
* ```
|
|
3872
|
+
*/
|
|
3873
|
+
async function readAppHeaders(publicDir) {
|
|
3874
|
+
const source = path.join(publicDir, "_headers");
|
|
3875
|
+
if (!existsSync(source)) return "";
|
|
3876
|
+
return readFile(source, "utf8");
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
|
|
3880
|
+
* per-file immutable bundle rules) followed by the app's own
|
|
3881
|
+
* `<publicDir>/_headers` content. App rules come LAST so they can override a
|
|
3882
|
+
* generated header — note Cloudflare comma-joins duplicates, so an app rule that
|
|
3883
|
+
* re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
|
|
3884
|
+
* the verbatim copy the public phase made, which is why this phase must run after
|
|
3885
|
+
* the outputs phase group.
|
|
3886
|
+
*
|
|
3887
|
+
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3888
|
+
* @returns The written file path + generated rule count.
|
|
3889
|
+
* @example
|
|
3890
|
+
* ```ts
|
|
3891
|
+
* const result = await generateCacheHeaders(ctx);
|
|
3892
|
+
* ```
|
|
3893
|
+
*/
|
|
3894
|
+
async function generateCacheHeaders(ctx) {
|
|
3895
|
+
const { outDir, publicDir, cacheHeaders } = ctx.config;
|
|
3896
|
+
const policy = resolvePolicy(cacheHeaders);
|
|
3897
|
+
const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
|
|
3898
|
+
const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
|
|
3899
|
+
const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
|
|
3900
|
+
if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
|
|
3901
|
+
rules: rules.length,
|
|
3902
|
+
limit: CLOUDFLARE_RULE_LIMIT
|
|
3903
|
+
});
|
|
3904
|
+
await mkdir(outDir, { recursive: true });
|
|
3905
|
+
const file = path.join(outDir, "_headers");
|
|
3906
|
+
await writeFile(file, content, "utf8");
|
|
3907
|
+
ctx.log.debug("build:cache-headers", {
|
|
3908
|
+
path: file,
|
|
3909
|
+
rules: rules.length
|
|
3910
|
+
});
|
|
3911
|
+
return {
|
|
3912
|
+
path: file,
|
|
3913
|
+
ruleCount: rules.length
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
//#endregion
|
|
3565
3917
|
//#region src/plugins/build/phases/content.ts
|
|
3566
3918
|
/**
|
|
3567
3919
|
* @file build phase 2 — content. Delegates entirely to the content plugin via
|
|
@@ -3701,10 +4053,34 @@ function createFeedChannel(site, defaultLocale) {
|
|
|
3701
4053
|
author: { name: site.author() }
|
|
3702
4054
|
});
|
|
3703
4055
|
}
|
|
4056
|
+
/** Matches a root-relative `src`/`href` attribute opening (`="/`), excluding protocol-relative `="//`. */
|
|
4057
|
+
const ROOT_RELATIVE_URL_ATTR = /\b(src|href)="\/(?!\/)/g;
|
|
4058
|
+
/**
|
|
4059
|
+
* Absolutize root-relative `src`/`href` URLs in rendered article HTML against the
|
|
4060
|
+
* site base URL. The content pipeline rewrites co-located images to root-relative
|
|
4061
|
+
* paths (`/<slug>/images/...`) — fine on the site, broken inside a feed, where
|
|
4062
|
+
* readers do not reliably resolve relative URLs. Protocol-relative (`//host/...`)
|
|
4063
|
+
* and already-absolute URLs are left untouched.
|
|
4064
|
+
*
|
|
4065
|
+
* @param html - The rendered article HTML.
|
|
4066
|
+
* @param baseUrl - The absolute site base URL (trailing slashes tolerated).
|
|
4067
|
+
* @returns The HTML with every root-relative URL made absolute.
|
|
4068
|
+
* @example
|
|
4069
|
+
* ```ts
|
|
4070
|
+
* absolutizeContentUrls('<img src="/post/images/a.webp">', "https://blog.dev");
|
|
4071
|
+
* // '<img src="https://blog.dev/post/images/a.webp">'
|
|
4072
|
+
* ```
|
|
4073
|
+
*/
|
|
4074
|
+
function absolutizeContentUrls(html, baseUrl) {
|
|
4075
|
+
let base = baseUrl;
|
|
4076
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
4077
|
+
return html.replaceAll(ROOT_RELATIVE_URL_ATTR, (_match, attribute) => `${attribute}="${base}/`);
|
|
4078
|
+
}
|
|
3704
4079
|
/**
|
|
3705
4080
|
* Append one article to the feed and return its canonical GUID. The canonical
|
|
3706
4081
|
* URL is the article's single stable identity — it is the item's id, guid, and
|
|
3707
|
-
* link at once.
|
|
4082
|
+
* link at once. Item content is the rendered HTML with root-relative URLs
|
|
4083
|
+
* absolutized against the site base, so embedded assets resolve in feed readers.
|
|
3708
4084
|
*
|
|
3709
4085
|
* @param feed - The feed channel to append to (mutated in place).
|
|
3710
4086
|
* @param article - The published article to add.
|
|
@@ -3723,7 +4099,7 @@ function addArticleItem(feed, article, site) {
|
|
|
3723
4099
|
guid: canonicalUrl,
|
|
3724
4100
|
link: canonicalUrl,
|
|
3725
4101
|
description: article.frontmatter.description,
|
|
3726
|
-
content: article.html,
|
|
4102
|
+
content: absolutizeContentUrls(article.html, site.url()),
|
|
3727
4103
|
date: new Date(article.frontmatter.date),
|
|
3728
4104
|
author: [{ name: article.frontmatter.author ?? site.author() }]
|
|
3729
4105
|
});
|
|
@@ -3838,10 +4214,14 @@ async function processImages(ctx, options = {}) {
|
|
|
3838
4214
|
//#endregion
|
|
3839
4215
|
//#region src/plugins/build/phases/locale-redirects.ts
|
|
3840
4216
|
/**
|
|
3841
|
-
* @file build phase — locale-redirects. For each
|
|
4217
|
+
* @file build phase — locale-redirects. For each REQUIRED-`{lang}` route (whose bare,
|
|
4218
|
+
* locale-less path would otherwise 404 — pages writes only `/{locale}/…`), emits a
|
|
3842
4219
|
* redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
|
|
3843
|
-
* bare path that points at the default-locale-prefixed URL.
|
|
3844
|
-
*
|
|
4220
|
+
* bare path that points at the default-locale-prefixed URL. OPTIONAL-`{lang:?}`
|
|
4221
|
+
* routes get NO redirect: the default locale is served BARE, so the pages phase
|
|
4222
|
+
* already writes the real content page at the bare path (plus a `/{defaultLocale}/…`
|
|
4223
|
+
* alias) — a redirect there would overwrite content. Deliberately does NOT emit a
|
|
4224
|
+
* Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
|
|
3845
4225
|
* `config.localeRedirects` (false/unset disables).
|
|
3846
4226
|
*
|
|
3847
4227
|
* When `head.defaultOgImage` is configured, each redirect page ALSO carries the
|
|
@@ -3893,6 +4273,12 @@ function pairRoutes(router) {
|
|
|
3893
4273
|
* redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
|
|
3894
4274
|
* (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
|
|
3895
4275
|
*
|
|
4276
|
+
* Only a REQUIRED-`{lang}` route produces a job. On an OPTIONAL-`{lang:?}` route the
|
|
4277
|
+
* compiled `toUrl` serves the default locale BARE (`toUrl({ lang: defaultLocale })`
|
|
4278
|
+
* equals the bare URL), so `target === bareUrl` → `null`. That is by design AND the
|
|
4279
|
+
* collision guard: the pages phase writes the default-locale content page at exactly
|
|
4280
|
+
* that bare file, and a redirect would overwrite it.
|
|
4281
|
+
*
|
|
3896
4282
|
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3897
4283
|
* @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
|
|
3898
4284
|
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
@@ -4002,7 +4388,10 @@ async function generateLocaleRedirects(ctx) {
|
|
|
4002
4388
|
//#region src/plugins/build/phases/not-found.ts
|
|
4003
4389
|
/**
|
|
4004
4390
|
* @file build phase — not-found. Emits `outDir/404.html` from configured route
|
|
4005
|
-
* content or a built-in default
|
|
4391
|
+
* content or a built-in default, substituting the `<!--moku:assets-->` family of
|
|
4392
|
+
* placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
|
|
4393
|
+
* no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
|
|
4394
|
+
* disables).
|
|
4006
4395
|
*/
|
|
4007
4396
|
/** The built-in default 404 page body when no custom route content is supplied. */
|
|
4008
4397
|
const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
|
|
@@ -4042,11 +4431,13 @@ async function resolveHtml(notFound) {
|
|
|
4042
4431
|
/**
|
|
4043
4432
|
* Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
|
|
4044
4433
|
* default page; `{ body }` writes the supplied HTML body content inside the
|
|
4045
|
-
* minimal document shell; `{ path }` writes the referenced HTML page file
|
|
4046
|
-
*
|
|
4047
|
-
*
|
|
4434
|
+
* minimal document shell; `{ path }` writes the referenced HTML page file (the
|
|
4435
|
+
* app owns the whole document). In every variant the `<!--moku:assets-->` /
|
|
4436
|
+
* `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
|
|
4437
|
+
* with the fingerprinted bundle tags — a page without placeholders passes through
|
|
4438
|
+
* byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
|
|
4048
4439
|
*
|
|
4049
|
-
* @param ctx - Plugin context (provides `config`, `log`).
|
|
4440
|
+
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
4050
4441
|
* @returns The written file path, or `null` when disabled.
|
|
4051
4442
|
* @example
|
|
4052
4443
|
* ```ts
|
|
@@ -4059,7 +4450,7 @@ async function generateNotFound(ctx) {
|
|
|
4059
4450
|
ctx.log.debug("build:not-found", { skipped: true });
|
|
4060
4451
|
return null;
|
|
4061
4452
|
}
|
|
4062
|
-
const html = await resolveHtml(notFound);
|
|
4453
|
+
const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
|
|
4063
4454
|
await mkdir(outDir, { recursive: true });
|
|
4064
4455
|
const file = path.join(outDir, "404.html");
|
|
4065
4456
|
await writeFile(file, html, "utf8");
|
|
@@ -4637,7 +5028,7 @@ function dataApi(ctx) {
|
|
|
4637
5028
|
* ```
|
|
4638
5029
|
*/
|
|
4639
5030
|
async write(entries, options) {
|
|
4640
|
-
const { writeData } = await import("./writer-
|
|
5031
|
+
const { writeData } = await import("./writer-CaoyORyZ.mjs");
|
|
4641
5032
|
return writeData(ctx, entries, options);
|
|
4642
5033
|
},
|
|
4643
5034
|
/**
|
|
@@ -4798,45 +5189,9 @@ const dataPlugin = createPlugin$1("data", {
|
|
|
4798
5189
|
const HEAD_PLACEHOLDER = "<!--moku:head-->";
|
|
4799
5190
|
/** Template placeholder for the SSR-rendered body HTML. */
|
|
4800
5191
|
const BODY_PLACEHOLDER = "<!--moku:body-->";
|
|
4801
|
-
/** Template placeholder for the injected asset `<link>`/`<script>` tags. */
|
|
4802
|
-
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
4803
5192
|
/** Template placeholder for the page's locale (`<html lang>`). */
|
|
4804
5193
|
const LANG_PLACEHOLDER = "<!--moku:lang-->";
|
|
4805
5194
|
/**
|
|
4806
|
-
* Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
|
|
4807
|
-
* as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
|
|
4808
|
-
*
|
|
4809
|
-
* @param ctx - Plugin context (provides `state`).
|
|
4810
|
-
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
4811
|
-
* @returns The hashed-path manifest entry, or an empty object when absent.
|
|
4812
|
-
* @example
|
|
4813
|
-
* ```ts
|
|
4814
|
-
* readManifest(ctx, "css");
|
|
4815
|
-
* ```
|
|
4816
|
-
*/
|
|
4817
|
-
function readManifest(ctx, kind) {
|
|
4818
|
-
const entry = ctx.state.buildCache.get(kind);
|
|
4819
|
-
return entry && typeof entry === "object" ? entry : {};
|
|
4820
|
-
}
|
|
4821
|
-
/**
|
|
4822
|
-
* Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
|
|
4823
|
-
* an empty string when `config.injectAssets === false`. Asset paths are emitted as
|
|
4824
|
-
* absolute (`/`-rooted) URLs.
|
|
4825
|
-
*
|
|
4826
|
-
* @param ctx - Plugin context (provides `state`, `config`).
|
|
4827
|
-
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
4828
|
-
* @example
|
|
4829
|
-
* ```ts
|
|
4830
|
-
* buildAssetTags(ctx);
|
|
4831
|
-
* ```
|
|
4832
|
-
*/
|
|
4833
|
-
function buildAssetTags(ctx) {
|
|
4834
|
-
if (ctx.config.injectAssets === false) return "";
|
|
4835
|
-
const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
|
|
4836
|
-
const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
|
|
4837
|
-
return [...css, ...js].join("");
|
|
4838
|
-
}
|
|
4839
|
-
/**
|
|
4840
5195
|
* Compose the full static HTML document with the in-code shell, injecting the
|
|
4841
5196
|
* build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
|
|
4842
5197
|
* metadata, not content) and the asset tags at the end of `<head>`.
|
|
@@ -4855,18 +5210,21 @@ function renderDocument(parts) {
|
|
|
4855
5210
|
* Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
|
|
4856
5211
|
* `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
|
|
4857
5212
|
* time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
|
|
4858
|
-
* shared template stays locale-correct across every locale.
|
|
5213
|
+
* shared template stays locale-correct across every locale. The split
|
|
5214
|
+
* `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
|
|
5215
|
+
* kind each — for shells that, e.g., link stylesheets in `<head>` but place
|
|
5216
|
+
* scripts at the end of `<body>`.
|
|
4859
5217
|
*
|
|
4860
5218
|
* @param template - The raw shell template HTML.
|
|
4861
5219
|
* @param parts - The composed head/body/assets/locale pieces.
|
|
4862
5220
|
* @returns The filled document string.
|
|
4863
5221
|
* @example
|
|
4864
5222
|
* ```ts
|
|
4865
|
-
* fillTemplate(shell, { head, body, assets, locale: "en" });
|
|
5223
|
+
* fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
|
|
4866
5224
|
* ```
|
|
4867
5225
|
*/
|
|
4868
5226
|
function fillTemplate(template, parts) {
|
|
4869
|
-
return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
5227
|
+
return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets).replaceAll(CSS_ASSETS_PLACEHOLDER, parts.assetsCss).replaceAll(JS_ASSETS_PLACEHOLDER, parts.assetsJs);
|
|
4870
5228
|
}
|
|
4871
5229
|
/**
|
|
4872
5230
|
* Resolve the compiled entry for a manifest definition, asserting the router
|
|
@@ -4915,29 +5273,44 @@ async function generateParameterSets(definition, locale, ctx) {
|
|
|
4915
5273
|
* locale). The generate context is the spec `{ locale, require, has }`, so a
|
|
4916
5274
|
* `.generate()` handler pulls sibling APIs the spec way.
|
|
4917
5275
|
*
|
|
5276
|
+
* Instances are deduplicated by resolved output file: a route whose pattern has no
|
|
5277
|
+
* lang placeholder (or whose `generate()` params omit `lang`) resolves to the SAME
|
|
5278
|
+
* `toFile` path for EVERY locale — without the guard each locale's render races on
|
|
5279
|
+
* one output file and the shipped HTML's locale is nondeterministic. The default
|
|
5280
|
+
* locale is expanded FIRST, so a collapsed route keeps its default-locale instance.
|
|
5281
|
+
*
|
|
4918
5282
|
* @param definition - The route definition from the manifest.
|
|
4919
5283
|
* @param locales - Active locale codes from i18n.
|
|
5284
|
+
* @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
|
|
4920
5285
|
* @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
|
|
4921
5286
|
* @param ctx - Plugin context (provides `require`/`has` for the generate context).
|
|
4922
|
-
* @returns The flattened list of page instances for this route.
|
|
5287
|
+
* @returns The flattened, file-deduplicated list of page instances for this route.
|
|
4923
5288
|
* @example
|
|
4924
5289
|
* ```ts
|
|
4925
|
-
* await expandRoute(def, ["en"], byPattern, ctx);
|
|
5290
|
+
* await expandRoute(def, ["en"], "en", byPattern, ctx);
|
|
4926
5291
|
* ```
|
|
4927
5292
|
*/
|
|
4928
|
-
async function expandRoute(definition, locales, byPattern, ctx) {
|
|
5293
|
+
async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
|
|
4929
5294
|
const entry = resolveEntry(byPattern, definition);
|
|
4930
5295
|
const { name } = entry;
|
|
5296
|
+
const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
|
|
4931
5297
|
const instances = [];
|
|
4932
|
-
|
|
5298
|
+
const claimedFiles = /* @__PURE__ */ new Set();
|
|
5299
|
+
for (const locale of orderedLocales) {
|
|
4933
5300
|
const parameterSets = await generateParameterSets(definition, locale, ctx);
|
|
4934
|
-
for (const raw of parameterSets)
|
|
4935
|
-
|
|
4936
|
-
entry
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
5301
|
+
for (const raw of parameterSets) {
|
|
5302
|
+
const params = raw ?? {};
|
|
5303
|
+
const file = entry.toFile(params);
|
|
5304
|
+
if (claimedFiles.has(file)) continue;
|
|
5305
|
+
claimedFiles.add(file);
|
|
5306
|
+
instances.push({
|
|
5307
|
+
definition,
|
|
5308
|
+
entry,
|
|
5309
|
+
name,
|
|
5310
|
+
params,
|
|
5311
|
+
locale
|
|
5312
|
+
});
|
|
5313
|
+
}
|
|
4941
5314
|
}
|
|
4942
5315
|
return instances;
|
|
4943
5316
|
}
|
|
@@ -5202,6 +5575,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
|
|
|
5202
5575
|
head: composeHeadHtml(ctx, instance, url, routeContext, data),
|
|
5203
5576
|
body: renderBodyCached(ctx, instance, routeContext, data, reuse),
|
|
5204
5577
|
assets: shell.assets,
|
|
5578
|
+
assetsCss: shell.assetsCss,
|
|
5579
|
+
assetsJs: shell.assetsJs,
|
|
5205
5580
|
locale
|
|
5206
5581
|
};
|
|
5207
5582
|
const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
|
|
@@ -5241,26 +5616,30 @@ async function prepareShell(ctx) {
|
|
|
5241
5616
|
const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
|
|
5242
5617
|
return {
|
|
5243
5618
|
assets: buildAssetTags(ctx),
|
|
5619
|
+
assetsCss: buildAssetTags(ctx, "css"),
|
|
5620
|
+
assetsJs: buildAssetTags(ctx, "js"),
|
|
5244
5621
|
template,
|
|
5245
5622
|
defaultLocale: ctx.require(i18nPlugin).defaultLocale()
|
|
5246
5623
|
};
|
|
5247
5624
|
}
|
|
5248
5625
|
/**
|
|
5249
5626
|
* Expand every manifest route into its concrete page instances across all locales
|
|
5250
|
-
* (delegating per-route expansion
|
|
5627
|
+
* (delegating per-route expansion — and per-route output-file deduplication — to
|
|
5628
|
+
* {@link expandRoute}) and flatten the result.
|
|
5251
5629
|
*
|
|
5252
5630
|
* @param manifest - The route definitions from `router.manifest()`.
|
|
5253
5631
|
* @param locales - Active locale codes from i18n.
|
|
5632
|
+
* @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
|
|
5254
5633
|
* @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
|
|
5255
5634
|
* @param ctx - Plugin context (provides `require`/`has` for generate contexts).
|
|
5256
5635
|
* @returns The flattened list of page instances to render.
|
|
5257
5636
|
* @example
|
|
5258
5637
|
* ```ts
|
|
5259
|
-
* const instances = await expandAllInstances(manifest, ["en"], byPattern, ctx);
|
|
5638
|
+
* const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
|
|
5260
5639
|
* ```
|
|
5261
5640
|
*/
|
|
5262
|
-
async function expandAllInstances(manifest, locales, byPattern, ctx) {
|
|
5263
|
-
return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern, ctx)))).flat();
|
|
5641
|
+
async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
|
|
5642
|
+
return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
|
|
5264
5643
|
}
|
|
5265
5644
|
/**
|
|
5266
5645
|
* Persist per-page client-data sidecars when the app opts into client navigation
|
|
@@ -5376,7 +5755,7 @@ async function renderPages(ctx, options) {
|
|
|
5376
5755
|
const byPattern = makeEntryMap(router);
|
|
5377
5756
|
if (!reuse) ctx.state.renderCache.clear();
|
|
5378
5757
|
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();
|
|
5758
|
+
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
5759
|
await writeDataSidecars(ctx, rendered, router.mode());
|
|
5381
5760
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
5382
5761
|
return {
|
|
@@ -5384,37 +5763,6 @@ async function renderPages(ctx, options) {
|
|
|
5384
5763
|
rootHtml: findRootHtml(rendered)
|
|
5385
5764
|
};
|
|
5386
5765
|
}
|
|
5387
|
-
/**
|
|
5388
|
-
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
5389
|
-
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
5390
|
-
* the source directory does not exist.
|
|
5391
|
-
*
|
|
5392
|
-
* @param ctx - Plugin context (provides `config`, `log`).
|
|
5393
|
-
* @returns The copy result, or `null` when the public directory is absent.
|
|
5394
|
-
* @example
|
|
5395
|
-
* ```ts
|
|
5396
|
-
* const result = await copyPublic(ctx);
|
|
5397
|
-
* ```
|
|
5398
|
-
*/
|
|
5399
|
-
async function copyPublic(ctx) {
|
|
5400
|
-
const from = ctx.config.publicDir ?? "public";
|
|
5401
|
-
if (!existsSync(from)) {
|
|
5402
|
-
ctx.log.debug("build:public", {
|
|
5403
|
-
skipped: true,
|
|
5404
|
-
from
|
|
5405
|
-
});
|
|
5406
|
-
return null;
|
|
5407
|
-
}
|
|
5408
|
-
await cp(from, ctx.config.outDir, { recursive: true });
|
|
5409
|
-
ctx.log.debug("build:public", {
|
|
5410
|
-
from,
|
|
5411
|
-
dest: ctx.config.outDir
|
|
5412
|
-
});
|
|
5413
|
-
return {
|
|
5414
|
-
from: path.normalize(from),
|
|
5415
|
-
copied: 1
|
|
5416
|
-
};
|
|
5417
|
-
}
|
|
5418
5766
|
//#endregion
|
|
5419
5767
|
//#region src/plugins/build/phases/sitemap.ts
|
|
5420
5768
|
/**
|
|
@@ -5451,7 +5799,22 @@ async function expandUrls(definition, entry, locales, ctx) {
|
|
|
5451
5799
|
return urls;
|
|
5452
5800
|
}
|
|
5453
5801
|
/**
|
|
5454
|
-
*
|
|
5802
|
+
* XML-escape a value for safe insertion into a text node (`& < > " '`). `&` is
|
|
5803
|
+
* escaped first so already-escaped entities are not double-escaped.
|
|
5804
|
+
*
|
|
5805
|
+
* @param raw - The unsafe string.
|
|
5806
|
+
* @returns The XML-escaped string.
|
|
5807
|
+
* @example
|
|
5808
|
+
* ```ts
|
|
5809
|
+
* escapeXml("https://blog.dev/a&b/"); // "https://blog.dev/a&b/"
|
|
5810
|
+
* ```
|
|
5811
|
+
*/
|
|
5812
|
+
function escapeXml(raw) {
|
|
5813
|
+
return raw.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Serialize a `<urlset>` sitemap document from a canonical URL set. Each `<loc>`
|
|
5817
|
+
* value is XML-escaped so slugs containing `&`/`<`/`>` cannot break the document.
|
|
5455
5818
|
*
|
|
5456
5819
|
* @param urls - The canonical (absolute) URLs.
|
|
5457
5820
|
* @returns The serialized sitemap XML.
|
|
@@ -5461,7 +5824,7 @@ async function expandUrls(definition, entry, locales, ctx) {
|
|
|
5461
5824
|
* ```
|
|
5462
5825
|
*/
|
|
5463
5826
|
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`;
|
|
5827
|
+
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
5828
|
}
|
|
5466
5829
|
/**
|
|
5467
5830
|
* Index the compiled router entries by their URL pattern, so each manifest
|
|
@@ -5567,7 +5930,8 @@ async function generateSitemap(ctx) {
|
|
|
5567
5930
|
const locales = ctx.require(i18nPlugin).locales();
|
|
5568
5931
|
const router = ctx.require(routerPlugin);
|
|
5569
5932
|
const byPattern = indexRoutesByPattern(router);
|
|
5570
|
-
const
|
|
5933
|
+
const relativeUrls = await collectRelativeUrls(router.manifest(), byPattern, locales, ctx);
|
|
5934
|
+
const urls = [...new Set(relativeUrls)].map((relative) => site.canonical(relative));
|
|
5571
5935
|
const xml = serializeSitemap(urls);
|
|
5572
5936
|
const robots = buildRobotsTxt(site);
|
|
5573
5937
|
await writeSitemapFiles(ctx.config.outDir, xml, robots);
|
|
@@ -5584,6 +5948,8 @@ async function generateSitemap(ctx) {
|
|
|
5584
5948
|
* @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
|
|
5585
5949
|
* emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
|
|
5586
5950
|
*/
|
|
5951
|
+
/** Error prefix for build pipeline runtime failures (spec/11 Part-3). */
|
|
5952
|
+
const ERROR_PREFIX$8 = "[web] build";
|
|
5587
5953
|
/** Matches a Markdown source path (a content edit). */
|
|
5588
5954
|
const MARKDOWN_PATH = /\.md$/;
|
|
5589
5955
|
/** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
|
|
@@ -5619,6 +5985,46 @@ function planIncrementalRebuild(changed) {
|
|
|
5619
5985
|
};
|
|
5620
5986
|
}
|
|
5621
5987
|
/**
|
|
5988
|
+
* Test whether a resolved path sits STRICTLY inside a resolved base directory —
|
|
5989
|
+
* equality does not count (the base itself is never "inside" itself).
|
|
5990
|
+
*
|
|
5991
|
+
* @param resolved - The resolved absolute candidate path.
|
|
5992
|
+
* @param baseResolved - The resolved absolute base directory.
|
|
5993
|
+
* @returns `true` when `resolved` is nested beneath `baseResolved`.
|
|
5994
|
+
* @example
|
|
5995
|
+
* ```ts
|
|
5996
|
+
* isStrictlyInside("/app/dist", "/app"); // true — but isStrictlyInside("/app", "/app") is false
|
|
5997
|
+
* ```
|
|
5998
|
+
*/
|
|
5999
|
+
function isStrictlyInside(resolved, baseResolved) {
|
|
6000
|
+
return resolved !== baseResolved && resolved.startsWith(baseResolved + path.sep);
|
|
6001
|
+
}
|
|
6002
|
+
/**
|
|
6003
|
+
* Assert that `outDir` is a SAFE target for the clean phase's recursive force-delete,
|
|
6004
|
+
* defending against a misconfiguration (`outDir: "/"`, `"."`, `"~"`, a `..` escape)
|
|
6005
|
+
* that would otherwise wipe the filesystem root, the home directory, or the project
|
|
6006
|
+
* itself. Mirrors the deploy plugin's `assertWithinRoot` posture, tightened for
|
|
6007
|
+
* deletion: a target is safe only when it sits STRICTLY inside the project root
|
|
6008
|
+
* (never the root itself) or strictly inside the OS temp directory (a disposable
|
|
6009
|
+
* area, used by preview/test builds) — and is never the home directory.
|
|
6010
|
+
*
|
|
6011
|
+
* @param outDir - The configured output directory (relative or absolute).
|
|
6012
|
+
* @param root - The absolute project root relative paths resolve against.
|
|
6013
|
+
* @returns The resolved absolute output directory.
|
|
6014
|
+
* @throws {Error} `[web] build.outDir` when the resolved target is unsafe to delete.
|
|
6015
|
+
* @example
|
|
6016
|
+
* ```ts
|
|
6017
|
+
* assertSafeCleanTarget("./dist", process.cwd()); // "<cwd>/dist"
|
|
6018
|
+
* ```
|
|
6019
|
+
*/
|
|
6020
|
+
function assertSafeCleanTarget(outDir, root) {
|
|
6021
|
+
const resolved = path.isAbsolute(outDir) ? path.resolve(outDir) : path.resolve(root, outDir);
|
|
6022
|
+
const rootResolved = path.resolve(root);
|
|
6023
|
+
const isHome = resolved === path.resolve(homedir());
|
|
6024
|
+
if ((isStrictlyInside(resolved, rootResolved) || isStrictlyInside(resolved, path.resolve(tmpdir()))) && !isHome) return resolved;
|
|
6025
|
+
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".`);
|
|
6026
|
+
}
|
|
6027
|
+
/**
|
|
5622
6028
|
* The static ordered list of pipeline phase names.
|
|
5623
6029
|
*
|
|
5624
6030
|
* @example
|
|
@@ -5638,6 +6044,7 @@ const PHASE_ORDER = [
|
|
|
5638
6044
|
"public",
|
|
5639
6045
|
"not-found",
|
|
5640
6046
|
"locale-redirects",
|
|
6047
|
+
"cache-headers",
|
|
5641
6048
|
"root-index"
|
|
5642
6049
|
];
|
|
5643
6050
|
/**
|
|
@@ -5668,17 +6075,23 @@ async function withPhase(ctx, phase, work) {
|
|
|
5668
6075
|
}
|
|
5669
6076
|
/**
|
|
5670
6077
|
* Reset the per-run state (manifest, buildCache, runId) and assign a fresh runId.
|
|
6078
|
+
* A clean run (no `skipClean`) also drops the OG image hash cache: the outDir wipe
|
|
6079
|
+
* deletes every `og/<slug>.png` the cache indexes, so honoring those warm entries
|
|
6080
|
+
* would skip rendering files that no longer exist. A `skipClean` (dev) run keeps
|
|
6081
|
+
* the cache — its PNGs survive on disk.
|
|
5671
6082
|
*
|
|
5672
6083
|
* @param ctx - The phase context whose `state` is reset.
|
|
6084
|
+
* @param options - The run options (only `skipClean` is consulted).
|
|
5673
6085
|
* @example
|
|
5674
6086
|
* ```ts
|
|
5675
|
-
* resetRun(ctx);
|
|
6087
|
+
* resetRun(ctx, options);
|
|
5676
6088
|
* ```
|
|
5677
6089
|
*/
|
|
5678
|
-
function resetRun(ctx) {
|
|
6090
|
+
function resetRun(ctx, options) {
|
|
5679
6091
|
ctx.state.manifest = null;
|
|
5680
6092
|
ctx.state.buildCache = /* @__PURE__ */ new Map();
|
|
5681
6093
|
ctx.state.runId = `${Date.now()}-${randomUUID()}`;
|
|
6094
|
+
if (!options?.skipClean) ctx.state.ogImageHashCache.clear();
|
|
5682
6095
|
}
|
|
5683
6096
|
/**
|
|
5684
6097
|
* Report each rejected outcome from a settled output batch as a `build:outputs`
|
|
@@ -5726,7 +6139,7 @@ async function runOutputs(ctx) {
|
|
|
5726
6139
|
}
|
|
5727
6140
|
/**
|
|
5728
6141
|
* Executes the full SSG pipeline for one run: clean → bundle → content/images →
|
|
5729
|
-
* pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
|
|
6142
|
+
* pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
|
|
5730
6143
|
* and `Promise.all` only — never inlines dependency domain logic. Emits a
|
|
5731
6144
|
* `build:phase` boundary per phase and `build:complete` once at the end.
|
|
5732
6145
|
*
|
|
@@ -5740,7 +6153,7 @@ async function runOutputs(ctx) {
|
|
|
5740
6153
|
*/
|
|
5741
6154
|
async function runPipeline(ctx, options) {
|
|
5742
6155
|
const started = Date.now();
|
|
5743
|
-
resetRun(ctx);
|
|
6156
|
+
resetRun(ctx, options);
|
|
5744
6157
|
const outDir = options?.outDir ?? ctx.config.outDir;
|
|
5745
6158
|
const phaseContext = {
|
|
5746
6159
|
...ctx,
|
|
@@ -5751,10 +6164,13 @@ async function runPipeline(ctx, options) {
|
|
|
5751
6164
|
}
|
|
5752
6165
|
};
|
|
5753
6166
|
const plan = planIncrementalRebuild(options?.changed);
|
|
5754
|
-
if (!options?.skipClean)
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
6167
|
+
if (!options?.skipClean) {
|
|
6168
|
+
assertSafeCleanTarget(outDir, process.cwd());
|
|
6169
|
+
await rm(outDir, {
|
|
6170
|
+
recursive: true,
|
|
6171
|
+
force: true
|
|
6172
|
+
});
|
|
6173
|
+
}
|
|
5758
6174
|
await mkdir(outDir, { recursive: true });
|
|
5759
6175
|
await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
|
|
5760
6176
|
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
|
|
@@ -5764,6 +6180,7 @@ async function runPipeline(ctx, options) {
|
|
|
5764
6180
|
const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
|
|
5765
6181
|
await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
|
|
5766
6182
|
await runOutputs(phaseContext);
|
|
6183
|
+
if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
|
|
5767
6184
|
await withPhase(phaseContext, "root-index", async () => {
|
|
5768
6185
|
if (pages.rootHtml !== null) await writeFile(path.join(outDir, "index.html"), pages.rootHtml, "utf8");
|
|
5769
6186
|
});
|
|
@@ -10050,6 +10467,23 @@ function isInternalLink(url) {
|
|
|
10050
10467
|
return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
|
|
10051
10468
|
}
|
|
10052
10469
|
/**
|
|
10470
|
+
* The navigable path of a URL or Location: pathname plus query string. The query
|
|
10471
|
+
* is part of page identity (the kernel's `currentUrl` is pathname + search), so
|
|
10472
|
+
* same-page checks, history entries, fetches, and scroll keys must all carry it —
|
|
10473
|
+
* comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
|
|
10474
|
+
* and the History fallback would drop the query from the address bar.
|
|
10475
|
+
*
|
|
10476
|
+
* @param target - The URL or Location to read.
|
|
10477
|
+
* @param target.pathname - The path component.
|
|
10478
|
+
* @param target.search - The query-string component (`""` when absent).
|
|
10479
|
+
* @returns The pathname + search string.
|
|
10480
|
+
* @example
|
|
10481
|
+
* pathWithSearch(new URL("https://x.dev/search?q=a")); // "/search?q=a"
|
|
10482
|
+
*/
|
|
10483
|
+
function pathWithSearch(target) {
|
|
10484
|
+
return target.pathname + target.search;
|
|
10485
|
+
}
|
|
10486
|
+
/**
|
|
10053
10487
|
* Save the current scroll position keyed by path (best-effort; ignores storage errors).
|
|
10054
10488
|
*
|
|
10055
10489
|
* @param path - The path to key the scroll position under.
|
|
@@ -10078,19 +10512,27 @@ function restoreScrollPosition(path) {
|
|
|
10078
10512
|
* Fetch a page and hand its HTML to the handlers; on any error fall back to a
|
|
10079
10513
|
* full browser navigation (`location.href = pathname`).
|
|
10080
10514
|
*
|
|
10515
|
+
* When `signal` aborts (this navigation was superseded by a newer one) the
|
|
10516
|
+
* fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
|
|
10517
|
+
* reload — the live navigation owns the document from that point on.
|
|
10518
|
+
*
|
|
10081
10519
|
* @param pathname - The destination pathname.
|
|
10082
10520
|
* @param handlers - The navigation lifecycle callbacks.
|
|
10521
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10083
10522
|
* @returns A promise that resolves once the swap (or fallback) is dispatched.
|
|
10084
10523
|
* @example
|
|
10085
|
-
* await performNavigation("/about", handlers);
|
|
10524
|
+
* await performNavigation("/about", handlers, navEvent.signal);
|
|
10086
10525
|
*/
|
|
10087
|
-
async function performNavigation(pathname, handlers) {
|
|
10526
|
+
async function performNavigation(pathname, handlers, signal) {
|
|
10088
10527
|
handlers.onStart(pathname);
|
|
10089
10528
|
try {
|
|
10090
|
-
const response = await fetch(pathname);
|
|
10529
|
+
const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
|
|
10091
10530
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
|
10092
|
-
|
|
10531
|
+
const html = await response.text();
|
|
10532
|
+
if (signal?.aborted) return;
|
|
10533
|
+
handlers.onEnd(html, pathname);
|
|
10093
10534
|
} catch {
|
|
10535
|
+
if (signal?.aborted) return;
|
|
10094
10536
|
handlers.onError();
|
|
10095
10537
|
location.href = pathname;
|
|
10096
10538
|
}
|
|
@@ -10129,23 +10571,29 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
|
|
|
10129
10571
|
* inside the same transition frame (after the DOM mutation) so component
|
|
10130
10572
|
* re-mounting is captured by the transition snapshot.
|
|
10131
10573
|
*
|
|
10574
|
+
* Returns whether the swap was dispatched: `false` when either document lacks
|
|
10575
|
+
* the `swapSelector` region, so the caller can fall back to a full navigation
|
|
10576
|
+
* instead of finishing the SPA nav against an un-swapped body.
|
|
10577
|
+
*
|
|
10132
10578
|
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
10133
10579
|
* @param swapSelector - CSS selector for the region to replace.
|
|
10134
10580
|
* @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
|
|
10135
10581
|
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
10136
10582
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10137
10583
|
* (forwarded to {@link runSwap} — e.g. scroll to the destination position).
|
|
10584
|
+
* @returns `true` when the swap was dispatched, `false` when either document lacks the region.
|
|
10138
10585
|
* @example
|
|
10139
10586
|
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
10140
10587
|
*/
|
|
10141
10588
|
function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
|
|
10142
10589
|
const newContent = doc.querySelector(swapSelector);
|
|
10143
10590
|
const currentContent = document.querySelector(swapSelector);
|
|
10144
|
-
if (!newContent || !currentContent) return;
|
|
10591
|
+
if (!newContent || !currentContent) return false;
|
|
10145
10592
|
runSwap(() => {
|
|
10146
10593
|
currentContent.replaceWith(newContent);
|
|
10147
10594
|
onSwapped();
|
|
10148
10595
|
}, viewTransitions, beforeCapture);
|
|
10596
|
+
return true;
|
|
10149
10597
|
}
|
|
10150
10598
|
/**
|
|
10151
10599
|
* Resolve a navigable internal URL from a click event, or `undefined` when the
|
|
@@ -10179,7 +10627,20 @@ function resolveClickTarget(event) {
|
|
|
10179
10627
|
* @example
|
|
10180
10628
|
* const dispose = attachHistoryFallback(handlers);
|
|
10181
10629
|
*/
|
|
10182
|
-
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
10630
|
+
function attachHistoryFallback(handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
|
|
10631
|
+
let controller;
|
|
10632
|
+
/**
|
|
10633
|
+
* Supersede the in-flight navigation (if any) and mint the next one's abort signal.
|
|
10634
|
+
*
|
|
10635
|
+
* @returns The fresh navigation's abort signal.
|
|
10636
|
+
* @example
|
|
10637
|
+
* const signal = supersede();
|
|
10638
|
+
*/
|
|
10639
|
+
const supersede = () => {
|
|
10640
|
+
controller?.abort();
|
|
10641
|
+
controller = new AbortController();
|
|
10642
|
+
return controller.signal;
|
|
10643
|
+
};
|
|
10183
10644
|
/**
|
|
10184
10645
|
* Intercept an internal-link click and run a History-API navigation.
|
|
10185
10646
|
*
|
|
@@ -10190,17 +10651,18 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10190
10651
|
const onClick = (event) => {
|
|
10191
10652
|
const url = resolveClickTarget(event);
|
|
10192
10653
|
if (!url) return;
|
|
10654
|
+
if (url.pathname === location.pathname && url.hash) return;
|
|
10193
10655
|
event.preventDefault();
|
|
10194
|
-
if (url
|
|
10656
|
+
if (pathWithSearch(url) === pathWithSearch(location)) {
|
|
10195
10657
|
window.scrollTo({
|
|
10196
10658
|
top: 0,
|
|
10197
10659
|
behavior: "smooth"
|
|
10198
10660
|
});
|
|
10199
10661
|
return;
|
|
10200
10662
|
}
|
|
10201
|
-
saveScrollPosition(location
|
|
10202
|
-
history.pushState({ scrollY: 0 }, "", url
|
|
10203
|
-
navigate(url
|
|
10663
|
+
saveScrollPosition(pathWithSearch(location));
|
|
10664
|
+
history.pushState({ scrollY: 0 }, "", pathWithSearch(url));
|
|
10665
|
+
navigate(pathWithSearch(url), true, supersede()).catch(() => {});
|
|
10204
10666
|
};
|
|
10205
10667
|
/**
|
|
10206
10668
|
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
@@ -10209,7 +10671,11 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10209
10671
|
* globalThis.addEventListener("popstate", onPopState);
|
|
10210
10672
|
*/
|
|
10211
10673
|
const onPopState = () => {
|
|
10212
|
-
|
|
10674
|
+
const path = pathWithSearch(location);
|
|
10675
|
+
const signal = supersede();
|
|
10676
|
+
navigate(path, false, signal).then(() => {
|
|
10677
|
+
if (!signal.aborted) restoreScrollPosition(path);
|
|
10678
|
+
}).catch(() => {});
|
|
10213
10679
|
};
|
|
10214
10680
|
document.addEventListener("click", onClick);
|
|
10215
10681
|
globalThis.addEventListener("popstate", onPopState);
|
|
@@ -10228,7 +10694,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10228
10694
|
* @example
|
|
10229
10695
|
* const dispose = attachNavigationApi(navigation, handlers);
|
|
10230
10696
|
*/
|
|
10231
|
-
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
10697
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
|
|
10232
10698
|
/**
|
|
10233
10699
|
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
10234
10700
|
*
|
|
@@ -10240,7 +10706,7 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
|
|
|
10240
10706
|
const url = new URL(navEvent.destination.url);
|
|
10241
10707
|
if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
|
|
10242
10708
|
if (!isInternalLink(url)) return;
|
|
10243
|
-
if (url
|
|
10709
|
+
if (pathWithSearch(url) === pathWithSearch(location)) {
|
|
10244
10710
|
navEvent.intercept({ handler: () => {
|
|
10245
10711
|
window.scrollTo({
|
|
10246
10712
|
top: 0,
|
|
@@ -10254,9 +10720,9 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
|
|
|
10254
10720
|
scroll: "manual",
|
|
10255
10721
|
handler: async () => {
|
|
10256
10722
|
if (navEvent.navigationType === "traverse") {
|
|
10257
|
-
await navigate(url
|
|
10258
|
-
navEvent.scroll();
|
|
10259
|
-
} else await navigate(url.
|
|
10723
|
+
await navigate(pathWithSearch(url), false, navEvent.signal);
|
|
10724
|
+
if (!navEvent.signal.aborted) navEvent.scroll();
|
|
10725
|
+
} else await navigate(pathWithSearch(url), true, navEvent.signal);
|
|
10260
10726
|
}
|
|
10261
10727
|
});
|
|
10262
10728
|
};
|
|
@@ -10442,6 +10908,11 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10442
10908
|
};
|
|
10443
10909
|
/**
|
|
10444
10910
|
* Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
|
|
10911
|
+
* When the region cannot be swapped (either document lacks the swap selector)
|
|
10912
|
+
* the SPA nav cannot complete — the head is already synced and the islands torn
|
|
10913
|
+
* down, so finishing would leave the OLD body under a NEW URL with a `spa:navigated`
|
|
10914
|
+
* claiming success. Fall back to a full browser navigation instead (mirroring
|
|
10915
|
+
* {@link performNavigation}'s fetch-error fallback).
|
|
10445
10916
|
*
|
|
10446
10917
|
* @param html - The fetched page HTML.
|
|
10447
10918
|
* @param pathname - The destination pathname.
|
|
@@ -10452,10 +10923,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10452
10923
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
10453
10924
|
syncHead(deps.head, doc);
|
|
10454
10925
|
unmountPageSpecific(state, emit);
|
|
10455
|
-
swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10926
|
+
if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10456
10927
|
scanAndMount(state, emit, resolved.swapSelector);
|
|
10457
10928
|
notifyNavEnd(state);
|
|
10458
|
-
}, applyPendingScroll)
|
|
10929
|
+
}, applyPendingScroll)) {
|
|
10930
|
+
handleError();
|
|
10931
|
+
location.href = pathname;
|
|
10932
|
+
return;
|
|
10933
|
+
}
|
|
10459
10934
|
state.currentUrl = pathname;
|
|
10460
10935
|
progress?.done();
|
|
10461
10936
|
emit("spa:navigated", { url: pathname });
|
|
@@ -10535,13 +11010,16 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10535
11010
|
*
|
|
10536
11011
|
* @param pathname - The destination pathname (recorded as the new current URL).
|
|
10537
11012
|
* @param resolvedRender - The inputs produced by {@link resolveDataRender}.
|
|
11013
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10538
11014
|
* @example
|
|
10539
11015
|
* await commitDataRender("/en/world/", resolved);
|
|
10540
11016
|
*/
|
|
10541
|
-
const commitDataRender = async (pathname, resolvedRender) => {
|
|
11017
|
+
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
11018
|
+
if (signal?.aborted) return;
|
|
10542
11019
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10543
11020
|
handleStart(pathname);
|
|
10544
11021
|
const { renderVNode } = await import("./render-BNe0s7fr.mjs");
|
|
11022
|
+
if (signal?.aborted) return;
|
|
10545
11023
|
syncDataHead(route, routeContext);
|
|
10546
11024
|
unmountPageSpecific(state, emit);
|
|
10547
11025
|
/**
|
|
@@ -10573,15 +11051,16 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10573
11051
|
* to HTML-over-fetch.
|
|
10574
11052
|
*
|
|
10575
11053
|
* @param pathname - The destination pathname (search stripped for matching).
|
|
11054
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10576
11055
|
* @returns `true` if the route was rendered from its data, else `false`.
|
|
10577
11056
|
* @example
|
|
10578
11057
|
* if (await tryDataRender("/en/world/")) return;
|
|
10579
11058
|
*/
|
|
10580
|
-
const tryDataRender = async (pathname) => {
|
|
11059
|
+
const tryDataRender = async (pathname, signal) => {
|
|
10581
11060
|
try {
|
|
10582
11061
|
const resolvedRender = await resolveDataRender(pathname);
|
|
10583
11062
|
if (resolvedRender === false) return false;
|
|
10584
|
-
await commitDataRender(pathname, resolvedRender);
|
|
11063
|
+
await commitDataRender(pathname, resolvedRender, signal);
|
|
10585
11064
|
return true;
|
|
10586
11065
|
} catch {
|
|
10587
11066
|
progress?.done();
|
|
@@ -10597,14 +11076,17 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10597
11076
|
* @param pathname - The destination pathname.
|
|
10598
11077
|
* @param scrollToTop - Whether the swap should scroll to top before its snapshot
|
|
10599
11078
|
* (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
|
|
11079
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
|
|
11080
|
+
* a superseded navigation never applies its swap (no stale last-write-wins).
|
|
10600
11081
|
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
10601
11082
|
* @example
|
|
10602
11083
|
* await navigate("/en/world/");
|
|
10603
11084
|
*/
|
|
10604
|
-
const navigate = async (pathname, scrollToTop = true) => {
|
|
11085
|
+
const navigate = async (pathname, scrollToTop = true, signal) => {
|
|
10605
11086
|
pendingScrollToTop = scrollToTop;
|
|
10606
|
-
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
10607
|
-
|
|
11087
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
|
|
11088
|
+
if (signal?.aborted) return;
|
|
11089
|
+
await performNavigation(pathname, handlers, signal);
|
|
10608
11090
|
};
|
|
10609
11091
|
return {
|
|
10610
11092
|
/**
|
|
@@ -11205,9 +11687,12 @@ function defaultRehypePlugins() {
|
|
|
11205
11687
|
* Clones the library default and additively allowlists the markup our custom
|
|
11206
11688
|
* transforms emit: `class` values (`pull-quote`, `section-divider`,
|
|
11207
11689
|
* `section-divider-ornament`) on `aside`/`div`/`span`, and the `loading`
|
|
11208
|
-
* attribute on `img`. `class`/`className
|
|
11209
|
-
*
|
|
11210
|
-
*
|
|
11690
|
+
* attribute on `img`. `class`/`className` are allowlisted globally (`*`, i.e.
|
|
11691
|
+
* on every element) so Shiki's class hooks survive the sanitize pass. `style`
|
|
11692
|
+
* is deliberately NOT global — CSS values are not sanitized, so a global
|
|
11693
|
+
* `style` allowlist would let untrusted content run overlay/exfiltration
|
|
11694
|
+
* styling; it is allowed only on `pre`/`code`, where Shiki places its
|
|
11695
|
+
* block-level theme background/foreground.
|
|
11211
11696
|
*
|
|
11212
11697
|
* @returns The extended, security-hardened sanitize schema.
|
|
11213
11698
|
* @example
|
|
@@ -11238,8 +11723,7 @@ function buildSanitizeSchema() {
|
|
|
11238
11723
|
"*": [
|
|
11239
11724
|
...baseAttributes["*"] ?? [],
|
|
11240
11725
|
"className",
|
|
11241
|
-
"class"
|
|
11242
|
-
"style"
|
|
11726
|
+
"class"
|
|
11243
11727
|
],
|
|
11244
11728
|
aside: [
|
|
11245
11729
|
...baseAttributes.aside ?? [],
|