@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.cjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_convention = require("./convention-
|
|
2
|
+
const require_convention = require("./convention-BpDfzX7e.cjs");
|
|
3
3
|
let _moku_labs_core = require("@moku-labs/core");
|
|
4
4
|
let node_fs = require("node:fs");
|
|
5
5
|
let node_crypto = require("node:crypto");
|
|
6
6
|
let node_fs_promises = require("node:fs/promises");
|
|
7
|
+
let node_os = require("node:os");
|
|
7
8
|
let node_path = require("node:path");
|
|
8
9
|
let node_path$1 = require_convention.__toESM(node_path, 1);
|
|
9
10
|
node_path = require_convention.__toESM(node_path);
|
|
@@ -13,7 +14,6 @@ let preact_render_to_string = require("preact-render-to-string");
|
|
|
13
14
|
let node_child_process = require("node:child_process");
|
|
14
15
|
let node_readline = require("node:readline");
|
|
15
16
|
let node_url = require("node:url");
|
|
16
|
-
let node_os = require("node:os");
|
|
17
17
|
let gray_matter = require("gray-matter");
|
|
18
18
|
gray_matter = require_convention.__toESM(gray_matter, 1);
|
|
19
19
|
let _shikijs_rehype = require("@shikijs/rehype");
|
|
@@ -41,7 +41,7 @@ let reading_time = require("reading-time");
|
|
|
41
41
|
reading_time = require_convention.__toESM(reading_time, 1);
|
|
42
42
|
//#region src/plugins/env/api.ts
|
|
43
43
|
/** Error prefix for all env API failures. */
|
|
44
|
-
const ERROR_PREFIX$
|
|
44
|
+
const ERROR_PREFIX$16 = "[web]";
|
|
45
45
|
/**
|
|
46
46
|
* Creates the env plugin API surface mounted at `ctx.env`. Closes over
|
|
47
47
|
* `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
|
|
@@ -85,7 +85,7 @@ function createEnvApi(ctx) {
|
|
|
85
85
|
*/
|
|
86
86
|
require(key) {
|
|
87
87
|
const value = resolved.get(key);
|
|
88
|
-
if (value === void 0) throw new Error(`${ERROR_PREFIX$
|
|
88
|
+
if (value === void 0) throw new Error(`${ERROR_PREFIX$16} env: required variable "${key}" is not defined.`);
|
|
89
89
|
return value;
|
|
90
90
|
},
|
|
91
91
|
/**
|
|
@@ -152,7 +152,7 @@ function createEnvState() {
|
|
|
152
152
|
/** Error message thrown by every frozen-map mutator. */
|
|
153
153
|
const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
|
|
154
154
|
/** Error prefix for all resolution-pipeline failures. */
|
|
155
|
-
const ERROR_PREFIX$
|
|
155
|
+
const ERROR_PREFIX$15 = "[web]";
|
|
156
156
|
/** The `Map` mutators redefined as throwers when a map is frozen. */
|
|
157
157
|
const FROZEN_METHODS = [
|
|
158
158
|
"set",
|
|
@@ -221,8 +221,8 @@ function crossCheckPublicPrefix(config) {
|
|
|
221
221
|
const { schema, publicPrefix } = config;
|
|
222
222
|
for (const [key, spec] of Object.entries(schema)) {
|
|
223
223
|
const hasPrefix = key.startsWith(publicPrefix);
|
|
224
|
-
if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$
|
|
225
|
-
if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$
|
|
224
|
+
if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$15} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
|
|
225
|
+
if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$15} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
/**
|
|
@@ -304,7 +304,7 @@ function validateSchema(ctx) {
|
|
|
304
304
|
crossCheckPublicPrefix(config);
|
|
305
305
|
for (const [key, spec] of Object.entries(schema)) {
|
|
306
306
|
if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
|
|
307
|
-
if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$
|
|
307
|
+
if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined by any provider or default.`);
|
|
308
308
|
}
|
|
309
309
|
populatePublicMap(schema, merged, state.publicMap);
|
|
310
310
|
populateResolved(merged, state.resolved);
|
|
@@ -912,7 +912,7 @@ const createCore = coreConfig.createCore;
|
|
|
912
912
|
//#endregion
|
|
913
913
|
//#region src/plugins/i18n/api.ts
|
|
914
914
|
/** Error prefix for all i18n lifecycle failures. */
|
|
915
|
-
const ERROR_PREFIX$
|
|
915
|
+
const ERROR_PREFIX$14 = "[web]";
|
|
916
916
|
/**
|
|
917
917
|
* Validates the resolved i18n config (fail-fast at `createApp`). Throws when
|
|
918
918
|
* `locales` is empty or when `defaultLocale` is not a member of `locales`.
|
|
@@ -928,8 +928,8 @@ const ERROR_PREFIX$13 = "[web]";
|
|
|
928
928
|
*/
|
|
929
929
|
function validateI18nConfig(ctx) {
|
|
930
930
|
const { locales, defaultLocale } = ctx.config;
|
|
931
|
-
if (locales.length === 0) throw new Error(`${ERROR_PREFIX$
|
|
932
|
-
if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$
|
|
931
|
+
if (locales.length === 0) throw new Error(`${ERROR_PREFIX$14} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
|
|
932
|
+
if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$14} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
|
|
933
933
|
}
|
|
934
934
|
/**
|
|
935
935
|
* Creates the i18n plugin API surface — locale registry accessors plus the
|
|
@@ -1419,6 +1419,15 @@ function createContentApi(ctx) {
|
|
|
1419
1419
|
* suppressed and throws the SAME not-found error (drafts indistinguishable from
|
|
1420
1420
|
* missing); in development and test drafts load normally.
|
|
1421
1421
|
*
|
|
1422
|
+
* Cache-first: when a preceding `loadAll()` (or earlier `load()`) already resolved +
|
|
1423
|
+
* rendered this `(slug, locale)`, the cached Article (full html included) is returned
|
|
1424
|
+
* without re-running the Markdown/Shiki pipeline — during a full build every
|
|
1425
|
+
* per-article route loader would otherwise re-render an article `loadAll()` just
|
|
1426
|
+
* rendered. Draft semantics are preserved: in production `loadAll()` filters drafts
|
|
1427
|
+
* out BEFORE caching and the production `load()` path throws before caching, so a
|
|
1428
|
+
* production cache hit is never a draft; misses fall through to a fresh resolve,
|
|
1429
|
+
* which suppresses drafts exactly as before.
|
|
1430
|
+
*
|
|
1422
1431
|
* @param slug - Article directory name.
|
|
1423
1432
|
* @param locale - Requested locale code.
|
|
1424
1433
|
* @returns The resolved Article.
|
|
@@ -1430,6 +1439,8 @@ function createContentApi(ctx) {
|
|
|
1430
1439
|
* ```
|
|
1431
1440
|
*/
|
|
1432
1441
|
async load(slug, locale) {
|
|
1442
|
+
const cached = ctx.state.articles.get(locale)?.get(slug);
|
|
1443
|
+
if (cached !== void 0) return cached;
|
|
1433
1444
|
const article = await resolveArticle(ctx, slug, locale);
|
|
1434
1445
|
if (article === null) throw articleNotFound(slug, locale);
|
|
1435
1446
|
if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
|
|
@@ -1610,7 +1621,7 @@ const contentPlugin = createPlugin$1("content", {
|
|
|
1610
1621
|
//#endregion
|
|
1611
1622
|
//#region src/plugins/site/api.ts
|
|
1612
1623
|
/** Error prefix for all site lifecycle/validation failures. */
|
|
1613
|
-
const ERROR_PREFIX$
|
|
1624
|
+
const ERROR_PREFIX$13 = "[web]";
|
|
1614
1625
|
/** URL protocols that qualify a parsed URL as an absolute http/https URL. */
|
|
1615
1626
|
const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
|
|
1616
1627
|
/**
|
|
@@ -1709,8 +1720,8 @@ function isAbsoluteUrl(value) {
|
|
|
1709
1720
|
* ```
|
|
1710
1721
|
*/
|
|
1711
1722
|
function validateSiteConfig(ctx) {
|
|
1712
|
-
if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$
|
|
1713
|
-
if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$
|
|
1723
|
+
if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$13} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
|
|
1724
|
+
if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$13} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
|
|
1714
1725
|
}
|
|
1715
1726
|
/**
|
|
1716
1727
|
* Creates the site plugin API surface — read-only accessors over frozen config
|
|
@@ -1892,21 +1903,42 @@ function bySpecificity(a, b) {
|
|
|
1892
1903
|
return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
|
|
1893
1904
|
}
|
|
1894
1905
|
/**
|
|
1906
|
+
* Decode a captured group's percent-escapes so params round-trip with
|
|
1907
|
+
* `buildUrl`'s encoding (matchers run against the encoded `location.pathname`).
|
|
1908
|
+
* Falls back to the raw text on malformed escapes (never throw mid-match).
|
|
1909
|
+
*
|
|
1910
|
+
* @param value - The raw captured segment text (possibly percent-encoded).
|
|
1911
|
+
* @returns The decoded param value, or the raw text on malformed escapes.
|
|
1912
|
+
* @example
|
|
1913
|
+
* ```ts
|
|
1914
|
+
* decodeGroupValue("c%23%20tips"); // "c# tips"
|
|
1915
|
+
* ```
|
|
1916
|
+
*/
|
|
1917
|
+
function decodeGroupValue(value) {
|
|
1918
|
+
try {
|
|
1919
|
+
return decodeURIComponent(value);
|
|
1920
|
+
} catch {
|
|
1921
|
+
return value;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1895
1925
|
* Extract named groups from a `URLPattern` match result, dropping numeric/regex
|
|
1896
1926
|
* group keys and `undefined` values so only declared, present params remain.
|
|
1927
|
+
* Each value is percent-DECODED ({@link decodeGroupValue}) back to the literal
|
|
1928
|
+
* value `buildUrl` was given.
|
|
1897
1929
|
*
|
|
1898
1930
|
* @param groups - The `URLPatternResult.pathname.groups` object.
|
|
1899
1931
|
* @returns A clean record of named params.
|
|
1900
1932
|
* @example
|
|
1901
1933
|
* ```ts
|
|
1902
|
-
* extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
|
|
1934
|
+
* extractGroups({ slug: "hello%20there", "0": "x" }); // { slug: "hello there" }
|
|
1903
1935
|
* ```
|
|
1904
1936
|
*/
|
|
1905
1937
|
function extractGroups(groups) {
|
|
1906
1938
|
const params = {};
|
|
1907
1939
|
for (const [key, value] of Object.entries(groups)) {
|
|
1908
1940
|
if (/^\d+$/.test(key)) continue;
|
|
1909
|
-
if (value !== void 0) params[key] = value;
|
|
1941
|
+
if (value !== void 0) params[key] = decodeGroupValue(value);
|
|
1910
1942
|
}
|
|
1911
1943
|
return params;
|
|
1912
1944
|
}
|
|
@@ -2049,7 +2081,7 @@ function matchRoute(compiled, pathname) {
|
|
|
2049
2081
|
//#endregion
|
|
2050
2082
|
//#region src/plugins/router/builders/compile.ts
|
|
2051
2083
|
/** Shared `[web]` error prefix for router validation failures. */
|
|
2052
|
-
const ERROR_PREFIX$
|
|
2084
|
+
const ERROR_PREFIX$12 = "[web] router";
|
|
2053
2085
|
/** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
|
|
2054
2086
|
const MAX_LANG_SEGMENTS = 1;
|
|
2055
2087
|
/**
|
|
@@ -2109,9 +2141,9 @@ function hasValidLangCount(pattern) {
|
|
|
2109
2141
|
* ```
|
|
2110
2142
|
*/
|
|
2111
2143
|
function assertRouteValid(name, pattern) {
|
|
2112
|
-
if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$
|
|
2113
|
-
if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$
|
|
2114
|
-
if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$
|
|
2144
|
+
if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern must start with "/" (got "${pattern}").`);
|
|
2145
|
+
if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
|
|
2146
|
+
if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$12}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
|
|
2115
2147
|
}
|
|
2116
2148
|
/**
|
|
2117
2149
|
* Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
|
|
@@ -2127,7 +2159,7 @@ function assertRouteValid(name, pattern) {
|
|
|
2127
2159
|
*/
|
|
2128
2160
|
function validateRoutes(routes) {
|
|
2129
2161
|
const names = Object.keys(routes);
|
|
2130
|
-
if (names.length === 0) throw new Error(`${ERROR_PREFIX$
|
|
2162
|
+
if (names.length === 0) throw new Error(`${ERROR_PREFIX$12}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
|
|
2131
2163
|
for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
|
|
2132
2164
|
}
|
|
2133
2165
|
/**
|
|
@@ -2161,27 +2193,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
|
|
|
2161
2193
|
return out.join("/");
|
|
2162
2194
|
}
|
|
2163
2195
|
/**
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
* `{lang:?}`
|
|
2168
|
-
*
|
|
2169
|
-
* The default locale is served at BARE paths: when `defaultLocale` is given, the
|
|
2170
|
-
* optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
|
|
2171
|
-
* resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
|
|
2196
|
+
* Substitute a pattern's placeholders one `/`-segment at a time (no backtracking
|
|
2197
|
+
* regex), passing each value through `encodeValue` — percent-encoding for
|
|
2198
|
+
* {@link buildUrl}, identity for {@link buildFilePath}. An absent optional segment
|
|
2199
|
+
* collapses (no double slash), as does `{lang:?}` for the bare `defaultLocale`.
|
|
2172
2200
|
*
|
|
2173
2201
|
* @param pattern - The route pattern.
|
|
2174
2202
|
* @param params - Param values to substitute.
|
|
2175
2203
|
* @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
|
|
2176
|
-
* @
|
|
2204
|
+
* @param encodeValue - Encoder applied to each substituted param value.
|
|
2205
|
+
* @returns The resolved relative path string.
|
|
2177
2206
|
* @example
|
|
2178
2207
|
* ```ts
|
|
2179
|
-
*
|
|
2180
|
-
* buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
|
|
2181
|
-
* buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
|
|
2208
|
+
* substitutePattern("/{slug}/", { slug: "a b" }, undefined, encodeURIComponent); // "/a%20b/"
|
|
2182
2209
|
* ```
|
|
2183
2210
|
*/
|
|
2184
|
-
function
|
|
2211
|
+
function substitutePattern(pattern, params, defaultLocale, encodeValue) {
|
|
2185
2212
|
const out = [];
|
|
2186
2213
|
for (const segment of pattern.split("/")) {
|
|
2187
2214
|
const placeholder = parsePlaceholder(segment);
|
|
@@ -2192,24 +2219,48 @@ function buildUrl(pattern, params, defaultLocale) {
|
|
|
2192
2219
|
const value = params[placeholder.name] ?? "";
|
|
2193
2220
|
if (placeholder.optional && value === "") continue;
|
|
2194
2221
|
if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
|
|
2195
|
-
out.push(value);
|
|
2222
|
+
out.push(encodeValue(value));
|
|
2196
2223
|
}
|
|
2197
2224
|
return out.join("/");
|
|
2198
2225
|
}
|
|
2199
2226
|
/**
|
|
2227
|
+
* Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`;
|
|
2228
|
+
* segment walk in {@link substitutePattern}). Substituted values are
|
|
2229
|
+
* percent-encoded so reserved characters (`#`, `?`, `&`, spaces, …) cannot
|
|
2230
|
+
* truncate the path or break the sitemap XML, and the URL round-trips through
|
|
2231
|
+
* the matchers (`extractGroups` decodes captures back).
|
|
2232
|
+
*
|
|
2233
|
+
* @param pattern - The route pattern.
|
|
2234
|
+
* @param params - Param values to substitute.
|
|
2235
|
+
* @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
|
|
2236
|
+
* @returns The resolved relative URL string.
|
|
2237
|
+
* @example
|
|
2238
|
+
* ```ts
|
|
2239
|
+
* buildUrl("/{slug}/", { slug: "a & b" }); // "/a%20%26%20b/"
|
|
2240
|
+
* buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
|
|
2241
|
+
* buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
|
|
2242
|
+
* ```
|
|
2243
|
+
*/
|
|
2244
|
+
function buildUrl(pattern, params, defaultLocale) {
|
|
2245
|
+
return substitutePattern(pattern, params, defaultLocale, encodeURIComponent);
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2200
2248
|
* Build an output file path from a pattern and params (always `…/index.html`).
|
|
2249
|
+
* Param values stay LITERAL: servers decode the encoded request path before
|
|
2250
|
+
* filesystem lookup, so on-disk names carry the decoded text.
|
|
2201
2251
|
*
|
|
2202
2252
|
* @param pattern - The route pattern.
|
|
2203
2253
|
* @param params - Param values to substitute.
|
|
2204
|
-
* @param defaultLocale - The locale served bare (
|
|
2254
|
+
* @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
|
|
2205
2255
|
* @returns The output file path, e.g. `hello/index.html`.
|
|
2206
2256
|
* @example
|
|
2207
2257
|
* ```ts
|
|
2208
|
-
* buildFilePath("/{slug}/", { slug: "hello" });
|
|
2258
|
+
* buildFilePath("/{slug}/", { slug: "hello" }); // "hello/index.html"
|
|
2259
|
+
* buildFilePath("/{tag}/", { tag: "a & b" }); // "a & b/index.html"
|
|
2209
2260
|
* ```
|
|
2210
2261
|
*/
|
|
2211
2262
|
function buildFilePath(pattern, params, defaultLocale) {
|
|
2212
|
-
const cleanPath =
|
|
2263
|
+
const cleanPath = substitutePattern(pattern, params, defaultLocale, (value) => value).replace(/^\//, "").replace(/\/$/, "");
|
|
2213
2264
|
return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
|
|
2214
2265
|
}
|
|
2215
2266
|
/**
|
|
@@ -2331,7 +2382,7 @@ function compileRoutes(input) {
|
|
|
2331
2382
|
* `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
|
|
2332
2383
|
*/
|
|
2333
2384
|
/** Error prefix for router API failures. */
|
|
2334
|
-
const ERROR_PREFIX$
|
|
2385
|
+
const ERROR_PREFIX$11 = "[web] router";
|
|
2335
2386
|
/**
|
|
2336
2387
|
* Validate a route map and compile it into the matcher table on `ctx.state`,
|
|
2337
2388
|
* resolving the global render `mode` + site base URL + i18n locales at call time.
|
|
@@ -2369,7 +2420,7 @@ function registerRoutes(ctx, routes) {
|
|
|
2369
2420
|
* ```
|
|
2370
2421
|
*/
|
|
2371
2422
|
function readTable(state) {
|
|
2372
|
-
if (state.table === null) throw new Error(`${ERROR_PREFIX$
|
|
2423
|
+
if (state.table === null) throw new Error(`${ERROR_PREFIX$11}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
|
|
2373
2424
|
return state.table;
|
|
2374
2425
|
}
|
|
2375
2426
|
/**
|
|
@@ -2453,7 +2504,7 @@ function createApi$5(ctx) {
|
|
|
2453
2504
|
*/
|
|
2454
2505
|
toUrl(routeName, params) {
|
|
2455
2506
|
const entry = readTable(state).byName.get(routeName);
|
|
2456
|
-
if (!entry) throw new Error(`${ERROR_PREFIX$
|
|
2507
|
+
if (!entry) throw new Error(`${ERROR_PREFIX$11}: unknown route name "${routeName}".\n Check the name matches a key in the route map registered via pluginConfigs.router.routes.`);
|
|
2457
2508
|
return entry.toUrl(params);
|
|
2458
2509
|
},
|
|
2459
2510
|
/**
|
|
@@ -3012,8 +3063,11 @@ function resolveImage(image, site) {
|
|
|
3012
3063
|
}
|
|
3013
3064
|
/**
|
|
3014
3065
|
* Build the per-locale `hreflang` alternates for a route, plus the `x-default`
|
|
3015
|
-
* fallback (the route's URL with
|
|
3016
|
-
* route's canonical URL for that locale,
|
|
3066
|
+
* fallback (the route's URL with `lang` STRIPPED, i.e. the bare default-locale
|
|
3067
|
+
* URL). Each alternate URL is the route's canonical URL for that locale,
|
|
3068
|
+
* absolutized against the site base URL. Stripping `lang` — rather than keeping
|
|
3069
|
+
* the page's own locale — keeps the x-default href byte-identical across every
|
|
3070
|
+
* locale variant of the route, as the hreflang spec requires.
|
|
3017
3071
|
*
|
|
3018
3072
|
* @param locales - The supported locale codes (drives the alternate set).
|
|
3019
3073
|
* @param route - The resolved route descriptor (provides `name` + `params`).
|
|
@@ -3029,7 +3083,9 @@ function buildHreflangAlternates(locales, route, router, site) {
|
|
|
3029
3083
|
lang: locale
|
|
3030
3084
|
})));
|
|
3031
3085
|
});
|
|
3032
|
-
const
|
|
3086
|
+
const bareParams = { ...route.params };
|
|
3087
|
+
delete bareParams.lang;
|
|
3088
|
+
const xDefaultHref = site.canonical(router.toUrl(route.name, bareParams));
|
|
3033
3089
|
alternates.push(hreflang(X_DEFAULT, xDefaultHref));
|
|
3034
3090
|
return alternates;
|
|
3035
3091
|
}
|
|
@@ -3206,7 +3262,7 @@ function serializeHead(elements) {
|
|
|
3206
3262
|
* it to a string. It holds no resource and caches no subscription.
|
|
3207
3263
|
*/
|
|
3208
3264
|
/** Error prefix for head API invariant failures. */
|
|
3209
|
-
const ERROR_PREFIX$
|
|
3265
|
+
const ERROR_PREFIX$10 = "[web] head";
|
|
3210
3266
|
/**
|
|
3211
3267
|
* Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
|
|
3212
3268
|
* `null` only before `onInit` assigns it, which cannot occur at render time).
|
|
@@ -3220,7 +3276,7 @@ const ERROR_PREFIX$9 = "[web] head";
|
|
|
3220
3276
|
* ```
|
|
3221
3277
|
*/
|
|
3222
3278
|
function readDefaults(state) {
|
|
3223
|
-
if (state.defaults === null) throw new Error(`${ERROR_PREFIX$
|
|
3279
|
+
if (state.defaults === null) throw new Error(`${ERROR_PREFIX$10}: defaults accessed before onInit normalized them.`);
|
|
3224
3280
|
return state.defaults;
|
|
3225
3281
|
}
|
|
3226
3282
|
/**
|
|
@@ -3288,7 +3344,7 @@ function createApi$4(ctx) {
|
|
|
3288
3344
|
//#endregion
|
|
3289
3345
|
//#region src/plugins/head/config.ts
|
|
3290
3346
|
/** Error prefix for all head config-validation failures. */
|
|
3291
|
-
const ERROR_PREFIX$
|
|
3347
|
+
const ERROR_PREFIX$9 = "[web] head";
|
|
3292
3348
|
/** The allowed `twitterCard` literals (also the runtime guard set). */
|
|
3293
3349
|
const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
|
|
3294
3350
|
/**
|
|
@@ -3315,8 +3371,8 @@ const defaultConfig$3 = { twitterCard: "summary_large_image" };
|
|
|
3315
3371
|
* ```
|
|
3316
3372
|
*/
|
|
3317
3373
|
function validateHeadConfig(config) {
|
|
3318
|
-
if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$
|
|
3319
|
-
if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$
|
|
3374
|
+
if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$9}: titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
|
|
3375
|
+
if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$9}: twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
|
|
3320
3376
|
}
|
|
3321
3377
|
/**
|
|
3322
3378
|
* Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
|
|
@@ -3430,7 +3486,9 @@ const headPlugin = createPlugin$1("head", {
|
|
|
3430
3486
|
//#region src/plugins/build/phases/bundle.ts
|
|
3431
3487
|
/**
|
|
3432
3488
|
* @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
|
|
3433
|
-
* outDir (honoring `config.minify`)
|
|
3489
|
+
* outDir (honoring `config.minify`) with content-hashed output naming; caches the
|
|
3490
|
+
* fingerprinted asset paths for the pages phase and the complete output list for
|
|
3491
|
+
* the cache-headers phase.
|
|
3434
3492
|
*/
|
|
3435
3493
|
/** Conventional CSS entry candidates (project-relative). */
|
|
3436
3494
|
const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
|
|
@@ -3441,16 +3499,38 @@ const JS_ENTRY_CANDIDATES = [
|
|
|
3441
3499
|
"src/main.ts"
|
|
3442
3500
|
];
|
|
3443
3501
|
/**
|
|
3502
|
+
* `Bun.build` output naming with a content hash in EVERY filename (entry points
|
|
3503
|
+
* included — Bun's default only hashes chunks/assets). A bundle's URL therefore
|
|
3504
|
+
* changes whenever its bytes change, which is what lets the cache-headers phase
|
|
3505
|
+
* mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
|
|
3506
|
+
* that changes the code ships a NEW URL instead of fighting a stale cached copy.
|
|
3507
|
+
* Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
|
|
3508
|
+
* names flow through with no app-side changes (hardcoded asset URLs must move to
|
|
3509
|
+
* the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
|
|
3510
|
+
* `chunk-` prefix (chunks were already hash-only named).
|
|
3511
|
+
*/
|
|
3512
|
+
const FINGERPRINT_NAMING = {
|
|
3513
|
+
entry: "[dir]/[name]-[hash].[ext]",
|
|
3514
|
+
chunk: "chunk-[hash].[ext]",
|
|
3515
|
+
asset: "[name]-[hash].[ext]"
|
|
3516
|
+
};
|
|
3517
|
+
/**
|
|
3444
3518
|
* The default bundler runner — adapts the built-in `Bun.build`.
|
|
3445
3519
|
*
|
|
3446
|
-
* @param options - Entry/outdir/minify settings forwarded to `Bun.build`.
|
|
3520
|
+
* @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
|
|
3447
3521
|
* @param options.entrypoints - Entry files for this build.
|
|
3448
3522
|
* @param options.outdir - Output directory.
|
|
3449
3523
|
* @param options.minify - Whether to minify.
|
|
3524
|
+
* @param options.splitting - Whether to split dynamic imports into lazy chunks.
|
|
3525
|
+
* @param options.target - The bundling target platform.
|
|
3526
|
+
* @param options.naming - Output naming templates (content-hashed filenames).
|
|
3527
|
+
* @param options.naming.entry - Naming template for entry-point outputs.
|
|
3528
|
+
* @param options.naming.chunk - Naming template for lazy split chunks.
|
|
3529
|
+
* @param options.naming.asset - Naming template for additional emitted assets.
|
|
3450
3530
|
* @returns The structural build result.
|
|
3451
3531
|
* @example
|
|
3452
3532
|
* ```ts
|
|
3453
|
-
* await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true });
|
|
3533
|
+
* await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
|
|
3454
3534
|
* ```
|
|
3455
3535
|
*/
|
|
3456
3536
|
async function defaultRunner(options) {
|
|
@@ -3519,7 +3599,9 @@ function normalizeAssetPath(absolutePath, outDir) {
|
|
|
3519
3599
|
}
|
|
3520
3600
|
/**
|
|
3521
3601
|
* Run one bundler pass for a single asset kind and record the hashed output
|
|
3522
|
-
* paths under `state.buildCache` keyed by the original entry basename.
|
|
3602
|
+
* paths under `state.buildCache` keyed by the original entry basename. Lazy
|
|
3603
|
+
* split chunks are emitted to disk but excluded from the recorded manifest
|
|
3604
|
+
* (they must never be embedded as eager `<script>` tags).
|
|
3523
3605
|
*
|
|
3524
3606
|
* @param ctx - The phase context (state + log).
|
|
3525
3607
|
* @param runner - The bundler runner to invoke.
|
|
@@ -3538,12 +3620,19 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3538
3620
|
const result = await runner({
|
|
3539
3621
|
entrypoints,
|
|
3540
3622
|
outdir,
|
|
3541
|
-
minify
|
|
3623
|
+
minify,
|
|
3624
|
+
splitting: true,
|
|
3625
|
+
target: "browser",
|
|
3626
|
+
naming: FINGERPRINT_NAMING
|
|
3542
3627
|
});
|
|
3543
3628
|
if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
|
|
3544
3629
|
const hashed = {};
|
|
3545
|
-
for (const output of result.outputs)
|
|
3630
|
+
for (const output of result.outputs) {
|
|
3631
|
+
if (output.kind === "chunk") continue;
|
|
3632
|
+
hashed[node_path$1.default.basename(output.path)] = normalizeAssetPath(output.path, outDir);
|
|
3633
|
+
}
|
|
3546
3634
|
ctx.state.buildCache.set(kind, hashed);
|
|
3635
|
+
ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
|
|
3547
3636
|
ctx.log.debug("build:bundle", {
|
|
3548
3637
|
kind,
|
|
3549
3638
|
count: result.outputs.length
|
|
@@ -3575,6 +3664,269 @@ async function bundle(ctx, options = {}) {
|
|
|
3575
3664
|
await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
|
|
3576
3665
|
}
|
|
3577
3666
|
//#endregion
|
|
3667
|
+
//#region src/plugins/build/phases/asset-tags.ts
|
|
3668
|
+
/** Template placeholder for the injected asset tags (stylesheets + scripts). */
|
|
3669
|
+
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
3670
|
+
/** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
|
|
3671
|
+
const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
|
|
3672
|
+
/** Template placeholder for the injected `<script>` tags ONLY. */
|
|
3673
|
+
const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
|
|
3674
|
+
/**
|
|
3675
|
+
* Read the bundle phase's fingerprinted asset manifest for one kind from
|
|
3676
|
+
* `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
|
|
3677
|
+
* reads at call sites).
|
|
3678
|
+
*
|
|
3679
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3680
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
3681
|
+
* @returns The fingerprinted-path manifest entry, or an empty object when absent.
|
|
3682
|
+
* @example
|
|
3683
|
+
* ```ts
|
|
3684
|
+
* readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
|
|
3685
|
+
* ```
|
|
3686
|
+
*/
|
|
3687
|
+
function readManifest(ctx, kind) {
|
|
3688
|
+
const entry = ctx.state.buildCache.get(kind);
|
|
3689
|
+
return entry && typeof entry === "object" ? entry : {};
|
|
3690
|
+
}
|
|
3691
|
+
/**
|
|
3692
|
+
* Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
|
|
3693
|
+
* chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
|
|
3694
|
+
* {@link readManifest} this includes chunks — it feeds the cache-headers phase's
|
|
3695
|
+
* per-file immutable rules, where every fingerprinted file counts, not just the
|
|
3696
|
+
* eagerly embedded entries.
|
|
3697
|
+
*
|
|
3698
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3699
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
3700
|
+
* @returns The publish-root-relative output paths, or an empty array when absent.
|
|
3701
|
+
* @example
|
|
3702
|
+
* ```ts
|
|
3703
|
+
* readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
|
|
3704
|
+
* ```
|
|
3705
|
+
*/
|
|
3706
|
+
function readBundleOutputs(ctx, kind) {
|
|
3707
|
+
const entry = ctx.state.buildCache.get(`${kind}:outputs`);
|
|
3708
|
+
return Array.isArray(entry) ? entry : [];
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
|
|
3712
|
+
*
|
|
3713
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3714
|
+
* @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
|
|
3715
|
+
* @example
|
|
3716
|
+
* ```ts
|
|
3717
|
+
* buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
|
|
3718
|
+
* ```
|
|
3719
|
+
*/
|
|
3720
|
+
function buildCssTags(ctx) {
|
|
3721
|
+
return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* Render the module `<script>` tags for the fingerprinted JS manifest.
|
|
3725
|
+
*
|
|
3726
|
+
* @param ctx - Plugin context (provides `state`).
|
|
3727
|
+
* @returns The concatenated `<script type="module">` tags (possibly `""`).
|
|
3728
|
+
* @example
|
|
3729
|
+
* ```ts
|
|
3730
|
+
* buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
|
|
3731
|
+
* ```
|
|
3732
|
+
*/
|
|
3733
|
+
function buildJsTags(ctx) {
|
|
3734
|
+
return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* Build the asset tag block from the fingerprinted manifests — both kinds by
|
|
3738
|
+
* default, or a single kind for the split `<!--moku:assets:css/js-->`
|
|
3739
|
+
* placeholders. Returns an empty string when `config.injectAssets === false`.
|
|
3740
|
+
* Asset paths are emitted as absolute (`/`-rooted) URLs.
|
|
3741
|
+
*
|
|
3742
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
3743
|
+
* @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
|
|
3744
|
+
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
3745
|
+
* @example
|
|
3746
|
+
* ```ts
|
|
3747
|
+
* buildAssetTags(ctx); // <link …><script …><\/script>
|
|
3748
|
+
* buildAssetTags(ctx, "css"); // <link …> only
|
|
3749
|
+
* ```
|
|
3750
|
+
*/
|
|
3751
|
+
function buildAssetTags(ctx, kind) {
|
|
3752
|
+
if (ctx.config.injectAssets === false) return "";
|
|
3753
|
+
if (kind === "css") return buildCssTags(ctx);
|
|
3754
|
+
if (kind === "js") return buildJsTags(ctx);
|
|
3755
|
+
return buildCssTags(ctx) + buildJsTags(ctx);
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
|
|
3759
|
+
* document: the combined block, the CSS-only block, and the JS-only block. A
|
|
3760
|
+
* document without placeholders passes through byte-identical — substitution is
|
|
3761
|
+
* strictly opt-in for app-owned pages (the not-found page).
|
|
3762
|
+
*
|
|
3763
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
3764
|
+
* @param html - The HTML document to substitute placeholders in.
|
|
3765
|
+
* @returns The document with all asset placeholders replaced.
|
|
3766
|
+
* @example
|
|
3767
|
+
* ```ts
|
|
3768
|
+
* substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
|
|
3769
|
+
* ```
|
|
3770
|
+
*/
|
|
3771
|
+
function substituteAssetPlaceholders(ctx, html) {
|
|
3772
|
+
return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
3776
|
+
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
3777
|
+
* the source directory does not exist.
|
|
3778
|
+
*
|
|
3779
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3780
|
+
* @returns The copy result, or `null` when the public directory is absent.
|
|
3781
|
+
* @example
|
|
3782
|
+
* ```ts
|
|
3783
|
+
* const result = await copyPublic(ctx);
|
|
3784
|
+
* ```
|
|
3785
|
+
*/
|
|
3786
|
+
async function copyPublic(ctx) {
|
|
3787
|
+
const from = ctx.config.publicDir ?? "public";
|
|
3788
|
+
if (!(0, node_fs.existsSync)(from)) {
|
|
3789
|
+
ctx.log.debug("build:public", {
|
|
3790
|
+
skipped: true,
|
|
3791
|
+
from
|
|
3792
|
+
});
|
|
3793
|
+
return null;
|
|
3794
|
+
}
|
|
3795
|
+
await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
|
|
3796
|
+
ctx.log.debug("build:public", {
|
|
3797
|
+
from,
|
|
3798
|
+
dest: ctx.config.outDir
|
|
3799
|
+
});
|
|
3800
|
+
return {
|
|
3801
|
+
from: node_path$1.default.normalize(from),
|
|
3802
|
+
copied: 1
|
|
3803
|
+
};
|
|
3804
|
+
}
|
|
3805
|
+
//#endregion
|
|
3806
|
+
//#region src/plugins/build/phases/cache-headers.ts
|
|
3807
|
+
/**
|
|
3808
|
+
* @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
|
|
3809
|
+
* header rules) so the CDN/browser cache can never serve a stale file: every
|
|
3810
|
+
* fingerprinted bundle gets a per-file immutable rule (its URL changes with its
|
|
3811
|
+
* content, so caching it forever is safe), and every OTHER URL — pages, content
|
|
3812
|
+
* images, feeds, data sidecars: stable URLs whose bytes may change between
|
|
3813
|
+
* deploys — gets a catch-all revalidation rule (an unchanged file still answers
|
|
3814
|
+
* `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
|
|
3815
|
+
* picked up immediately). The app's own `<publicDir>/_headers` rules are appended
|
|
3816
|
+
* AFTER the generated ones so the app can override them. Gated by
|
|
3817
|
+
* `config.cacheHeaders` (`false` disables; default on).
|
|
3818
|
+
*/
|
|
3819
|
+
/**
|
|
3820
|
+
* `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
|
|
3821
|
+
* the bytes behind a given URL can never change — cache them for a year, immutably.
|
|
3822
|
+
*/
|
|
3823
|
+
const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
|
|
3824
|
+
/**
|
|
3825
|
+
* `Cache-Control` for everything else (stable URLs): always revalidate with the
|
|
3826
|
+
* origin. Unchanged files still serve from cache via a `304` ETag round-trip;
|
|
3827
|
+
* changed files are fetched fresh — never stale, still cheap.
|
|
3828
|
+
*/
|
|
3829
|
+
const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
|
|
3830
|
+
/**
|
|
3831
|
+
* Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
|
|
3832
|
+
* a site whose bundle count pushes past the cap needs a warning, not silence.
|
|
3833
|
+
*/
|
|
3834
|
+
const CLOUDFLARE_RULE_LIMIT = 100;
|
|
3835
|
+
/**
|
|
3836
|
+
* Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
|
|
3837
|
+
* object — `false` never reaches here; the pipeline gates the phase off).
|
|
3838
|
+
*
|
|
3839
|
+
* @param cacheHeaders - The `config.cacheHeaders` value.
|
|
3840
|
+
* @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
|
|
3841
|
+
* @example
|
|
3842
|
+
* ```ts
|
|
3843
|
+
* resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
|
|
3844
|
+
* ```
|
|
3845
|
+
*/
|
|
3846
|
+
function resolvePolicy(cacheHeaders) {
|
|
3847
|
+
const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
|
|
3848
|
+
return {
|
|
3849
|
+
assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
|
|
3850
|
+
pages: policy.pages ?? DEFAULT_PAGES_CACHE
|
|
3851
|
+
};
|
|
3852
|
+
}
|
|
3853
|
+
/**
|
|
3854
|
+
* Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
|
|
3855
|
+
* one immutable rule per fingerprinted bundle file. Cloudflare applies every
|
|
3856
|
+
* matching rule and comma-joins duplicate headers (it does NOT override), so each
|
|
3857
|
+
* per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
|
|
3858
|
+
* before attaching its own — otherwise a bundle would be served with two joined,
|
|
3859
|
+
* contradictory `Cache-Control` values.
|
|
3860
|
+
*
|
|
3861
|
+
* @param files - The fingerprinted bundle web paths (publish-root-relative).
|
|
3862
|
+
* @param policy - The resolved `Cache-Control` values.
|
|
3863
|
+
* @param policy.assets - The value for fingerprinted bundles.
|
|
3864
|
+
* @param policy.pages - The catch-all value for everything else.
|
|
3865
|
+
* @returns The generated rule blocks, in emission order.
|
|
3866
|
+
* @example
|
|
3867
|
+
* ```ts
|
|
3868
|
+
* composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
|
|
3869
|
+
* ```
|
|
3870
|
+
*/
|
|
3871
|
+
function composeRules(files, policy) {
|
|
3872
|
+
return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
|
|
3873
|
+
}
|
|
3874
|
+
/**
|
|
3875
|
+
* Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
|
|
3876
|
+
* phase may have placed in outDir — composing from the source keeps this phase
|
|
3877
|
+
* idempotent and independent of phase ordering). Returns `""` when absent.
|
|
3878
|
+
*
|
|
3879
|
+
* @param publicDir - The configured public directory (or the default).
|
|
3880
|
+
* @returns The app's `_headers` content, or `""` when the file does not exist.
|
|
3881
|
+
* @example
|
|
3882
|
+
* ```ts
|
|
3883
|
+
* const appRules = await readAppHeaders("public");
|
|
3884
|
+
* ```
|
|
3885
|
+
*/
|
|
3886
|
+
async function readAppHeaders(publicDir) {
|
|
3887
|
+
const source = node_path$1.default.join(publicDir, "_headers");
|
|
3888
|
+
if (!(0, node_fs.existsSync)(source)) return "";
|
|
3889
|
+
return (0, node_fs_promises.readFile)(source, "utf8");
|
|
3890
|
+
}
|
|
3891
|
+
/**
|
|
3892
|
+
* Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
|
|
3893
|
+
* per-file immutable bundle rules) followed by the app's own
|
|
3894
|
+
* `<publicDir>/_headers` content. App rules come LAST so they can override a
|
|
3895
|
+
* generated header — note Cloudflare comma-joins duplicates, so an app rule that
|
|
3896
|
+
* re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
|
|
3897
|
+
* the verbatim copy the public phase made, which is why this phase must run after
|
|
3898
|
+
* the outputs phase group.
|
|
3899
|
+
*
|
|
3900
|
+
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3901
|
+
* @returns The written file path + generated rule count.
|
|
3902
|
+
* @example
|
|
3903
|
+
* ```ts
|
|
3904
|
+
* const result = await generateCacheHeaders(ctx);
|
|
3905
|
+
* ```
|
|
3906
|
+
*/
|
|
3907
|
+
async function generateCacheHeaders(ctx) {
|
|
3908
|
+
const { outDir, publicDir, cacheHeaders } = ctx.config;
|
|
3909
|
+
const policy = resolvePolicy(cacheHeaders);
|
|
3910
|
+
const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
|
|
3911
|
+
const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
|
|
3912
|
+
const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
|
|
3913
|
+
if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
|
|
3914
|
+
rules: rules.length,
|
|
3915
|
+
limit: CLOUDFLARE_RULE_LIMIT
|
|
3916
|
+
});
|
|
3917
|
+
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
3918
|
+
const file = node_path$1.default.join(outDir, "_headers");
|
|
3919
|
+
await (0, node_fs_promises.writeFile)(file, content, "utf8");
|
|
3920
|
+
ctx.log.debug("build:cache-headers", {
|
|
3921
|
+
path: file,
|
|
3922
|
+
rules: rules.length
|
|
3923
|
+
});
|
|
3924
|
+
return {
|
|
3925
|
+
path: file,
|
|
3926
|
+
ruleCount: rules.length
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
//#endregion
|
|
3578
3930
|
//#region src/plugins/build/phases/content.ts
|
|
3579
3931
|
/**
|
|
3580
3932
|
* @file build phase 2 — content. Delegates entirely to the content plugin via
|
|
@@ -3714,10 +4066,34 @@ function createFeedChannel(site, defaultLocale) {
|
|
|
3714
4066
|
author: { name: site.author() }
|
|
3715
4067
|
});
|
|
3716
4068
|
}
|
|
4069
|
+
/** Matches a root-relative `src`/`href` attribute opening (`="/`), excluding protocol-relative `="//`. */
|
|
4070
|
+
const ROOT_RELATIVE_URL_ATTR = /\b(src|href)="\/(?!\/)/g;
|
|
4071
|
+
/**
|
|
4072
|
+
* Absolutize root-relative `src`/`href` URLs in rendered article HTML against the
|
|
4073
|
+
* site base URL. The content pipeline rewrites co-located images to root-relative
|
|
4074
|
+
* paths (`/<slug>/images/...`) — fine on the site, broken inside a feed, where
|
|
4075
|
+
* readers do not reliably resolve relative URLs. Protocol-relative (`//host/...`)
|
|
4076
|
+
* and already-absolute URLs are left untouched.
|
|
4077
|
+
*
|
|
4078
|
+
* @param html - The rendered article HTML.
|
|
4079
|
+
* @param baseUrl - The absolute site base URL (trailing slashes tolerated).
|
|
4080
|
+
* @returns The HTML with every root-relative URL made absolute.
|
|
4081
|
+
* @example
|
|
4082
|
+
* ```ts
|
|
4083
|
+
* absolutizeContentUrls('<img src="/post/images/a.webp">', "https://blog.dev");
|
|
4084
|
+
* // '<img src="https://blog.dev/post/images/a.webp">'
|
|
4085
|
+
* ```
|
|
4086
|
+
*/
|
|
4087
|
+
function absolutizeContentUrls(html, baseUrl) {
|
|
4088
|
+
let base = baseUrl;
|
|
4089
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
4090
|
+
return html.replaceAll(ROOT_RELATIVE_URL_ATTR, (_match, attribute) => `${attribute}="${base}/`);
|
|
4091
|
+
}
|
|
3717
4092
|
/**
|
|
3718
4093
|
* Append one article to the feed and return its canonical GUID. The canonical
|
|
3719
4094
|
* URL is the article's single stable identity — it is the item's id, guid, and
|
|
3720
|
-
* link at once.
|
|
4095
|
+
* link at once. Item content is the rendered HTML with root-relative URLs
|
|
4096
|
+
* absolutized against the site base, so embedded assets resolve in feed readers.
|
|
3721
4097
|
*
|
|
3722
4098
|
* @param feed - The feed channel to append to (mutated in place).
|
|
3723
4099
|
* @param article - The published article to add.
|
|
@@ -3736,7 +4112,7 @@ function addArticleItem(feed$1, article, site) {
|
|
|
3736
4112
|
guid: canonicalUrl,
|
|
3737
4113
|
link: canonicalUrl,
|
|
3738
4114
|
description: article.frontmatter.description,
|
|
3739
|
-
content: article.html,
|
|
4115
|
+
content: absolutizeContentUrls(article.html, site.url()),
|
|
3740
4116
|
date: new Date(article.frontmatter.date),
|
|
3741
4117
|
author: [{ name: article.frontmatter.author ?? site.author() }]
|
|
3742
4118
|
});
|
|
@@ -3851,10 +4227,14 @@ async function processImages(ctx, options = {}) {
|
|
|
3851
4227
|
//#endregion
|
|
3852
4228
|
//#region src/plugins/build/phases/locale-redirects.ts
|
|
3853
4229
|
/**
|
|
3854
|
-
* @file build phase — locale-redirects. For each
|
|
4230
|
+
* @file build phase — locale-redirects. For each REQUIRED-`{lang}` route (whose bare,
|
|
4231
|
+
* locale-less path would otherwise 404 — pages writes only `/{locale}/…`), emits a
|
|
3855
4232
|
* redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
|
|
3856
|
-
* bare path that points at the default-locale-prefixed URL.
|
|
3857
|
-
*
|
|
4233
|
+
* bare path that points at the default-locale-prefixed URL. OPTIONAL-`{lang:?}`
|
|
4234
|
+
* routes get NO redirect: the default locale is served BARE, so the pages phase
|
|
4235
|
+
* already writes the real content page at the bare path (plus a `/{defaultLocale}/…`
|
|
4236
|
+
* alias) — a redirect there would overwrite content. Deliberately does NOT emit a
|
|
4237
|
+
* Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
|
|
3858
4238
|
* `config.localeRedirects` (false/unset disables).
|
|
3859
4239
|
*
|
|
3860
4240
|
* When `head.defaultOgImage` is configured, each redirect page ALSO carries the
|
|
@@ -3906,6 +4286,12 @@ function pairRoutes(router) {
|
|
|
3906
4286
|
* redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
|
|
3907
4287
|
* (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
|
|
3908
4288
|
*
|
|
4289
|
+
* Only a REQUIRED-`{lang}` route produces a job. On an OPTIONAL-`{lang:?}` route the
|
|
4290
|
+
* compiled `toUrl` serves the default locale BARE (`toUrl({ lang: defaultLocale })`
|
|
4291
|
+
* equals the bare URL), so `target === bareUrl` → `null`. That is by design AND the
|
|
4292
|
+
* collision guard: the pages phase writes the default-locale content page at exactly
|
|
4293
|
+
* that bare file, and a redirect would overwrite it.
|
|
4294
|
+
*
|
|
3909
4295
|
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3910
4296
|
* @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
|
|
3911
4297
|
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
@@ -4015,7 +4401,10 @@ async function generateLocaleRedirects(ctx) {
|
|
|
4015
4401
|
//#region src/plugins/build/phases/not-found.ts
|
|
4016
4402
|
/**
|
|
4017
4403
|
* @file build phase — not-found. Emits `outDir/404.html` from configured route
|
|
4018
|
-
* content or a built-in default
|
|
4404
|
+
* content or a built-in default, substituting the `<!--moku:assets-->` family of
|
|
4405
|
+
* placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
|
|
4406
|
+
* no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
|
|
4407
|
+
* disables).
|
|
4019
4408
|
*/
|
|
4020
4409
|
/** The built-in default 404 page body when no custom route content is supplied. */
|
|
4021
4410
|
const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
|
|
@@ -4055,11 +4444,13 @@ async function resolveHtml(notFound) {
|
|
|
4055
4444
|
/**
|
|
4056
4445
|
* Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
|
|
4057
4446
|
* default page; `{ body }` writes the supplied HTML body content inside the
|
|
4058
|
-
* minimal document shell; `{ path }` writes the referenced HTML page file
|
|
4059
|
-
*
|
|
4060
|
-
*
|
|
4447
|
+
* minimal document shell; `{ path }` writes the referenced HTML page file (the
|
|
4448
|
+
* app owns the whole document). In every variant the `<!--moku:assets-->` /
|
|
4449
|
+
* `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
|
|
4450
|
+
* with the fingerprinted bundle tags — a page without placeholders passes through
|
|
4451
|
+
* byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
|
|
4061
4452
|
*
|
|
4062
|
-
* @param ctx - Plugin context (provides `config`, `log`).
|
|
4453
|
+
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
4063
4454
|
* @returns The written file path, or `null` when disabled.
|
|
4064
4455
|
* @example
|
|
4065
4456
|
* ```ts
|
|
@@ -4072,7 +4463,7 @@ async function generateNotFound(ctx) {
|
|
|
4072
4463
|
ctx.log.debug("build:not-found", { skipped: true });
|
|
4073
4464
|
return null;
|
|
4074
4465
|
}
|
|
4075
|
-
const html = await resolveHtml(notFound);
|
|
4466
|
+
const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
|
|
4076
4467
|
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
4077
4468
|
const file = node_path$1.default.join(outDir, "404.html");
|
|
4078
4469
|
await (0, node_fs_promises.writeFile)(file, html, "utf8");
|
|
@@ -4650,7 +5041,7 @@ function dataApi(ctx) {
|
|
|
4650
5041
|
* ```
|
|
4651
5042
|
*/
|
|
4652
5043
|
async write(entries, options) {
|
|
4653
|
-
const { writeData } = await Promise.resolve().then(() => require("./writer-
|
|
5044
|
+
const { writeData } = await Promise.resolve().then(() => require("./writer-JdhX1Wld.cjs"));
|
|
4654
5045
|
return writeData(ctx, entries, options);
|
|
4655
5046
|
},
|
|
4656
5047
|
/**
|
|
@@ -4811,45 +5202,9 @@ const dataPlugin = createPlugin$1("data", {
|
|
|
4811
5202
|
const HEAD_PLACEHOLDER = "<!--moku:head-->";
|
|
4812
5203
|
/** Template placeholder for the SSR-rendered body HTML. */
|
|
4813
5204
|
const BODY_PLACEHOLDER = "<!--moku:body-->";
|
|
4814
|
-
/** Template placeholder for the injected asset `<link>`/`<script>` tags. */
|
|
4815
|
-
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
4816
5205
|
/** Template placeholder for the page's locale (`<html lang>`). */
|
|
4817
5206
|
const LANG_PLACEHOLDER = "<!--moku:lang-->";
|
|
4818
5207
|
/**
|
|
4819
|
-
* Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
|
|
4820
|
-
* as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
|
|
4821
|
-
*
|
|
4822
|
-
* @param ctx - Plugin context (provides `state`).
|
|
4823
|
-
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
4824
|
-
* @returns The hashed-path manifest entry, or an empty object when absent.
|
|
4825
|
-
* @example
|
|
4826
|
-
* ```ts
|
|
4827
|
-
* readManifest(ctx, "css");
|
|
4828
|
-
* ```
|
|
4829
|
-
*/
|
|
4830
|
-
function readManifest(ctx, kind) {
|
|
4831
|
-
const entry = ctx.state.buildCache.get(kind);
|
|
4832
|
-
return entry && typeof entry === "object" ? entry : {};
|
|
4833
|
-
}
|
|
4834
|
-
/**
|
|
4835
|
-
* Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
|
|
4836
|
-
* an empty string when `config.injectAssets === false`. Asset paths are emitted as
|
|
4837
|
-
* absolute (`/`-rooted) URLs.
|
|
4838
|
-
*
|
|
4839
|
-
* @param ctx - Plugin context (provides `state`, `config`).
|
|
4840
|
-
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
4841
|
-
* @example
|
|
4842
|
-
* ```ts
|
|
4843
|
-
* buildAssetTags(ctx);
|
|
4844
|
-
* ```
|
|
4845
|
-
*/
|
|
4846
|
-
function buildAssetTags(ctx) {
|
|
4847
|
-
if (ctx.config.injectAssets === false) return "";
|
|
4848
|
-
const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
|
|
4849
|
-
const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
|
|
4850
|
-
return [...css, ...js].join("");
|
|
4851
|
-
}
|
|
4852
|
-
/**
|
|
4853
5208
|
* Compose the full static HTML document with the in-code shell, injecting the
|
|
4854
5209
|
* build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
|
|
4855
5210
|
* metadata, not content) and the asset tags at the end of `<head>`.
|
|
@@ -4868,18 +5223,21 @@ function renderDocument(parts) {
|
|
|
4868
5223
|
* Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
|
|
4869
5224
|
* `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
|
|
4870
5225
|
* time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
|
|
4871
|
-
* shared template stays locale-correct across every locale.
|
|
5226
|
+
* shared template stays locale-correct across every locale. The split
|
|
5227
|
+
* `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
|
|
5228
|
+
* kind each — for shells that, e.g., link stylesheets in `<head>` but place
|
|
5229
|
+
* scripts at the end of `<body>`.
|
|
4872
5230
|
*
|
|
4873
5231
|
* @param template - The raw shell template HTML.
|
|
4874
5232
|
* @param parts - The composed head/body/assets/locale pieces.
|
|
4875
5233
|
* @returns The filled document string.
|
|
4876
5234
|
* @example
|
|
4877
5235
|
* ```ts
|
|
4878
|
-
* fillTemplate(shell, { head, body, assets, locale: "en" });
|
|
5236
|
+
* fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
|
|
4879
5237
|
* ```
|
|
4880
5238
|
*/
|
|
4881
5239
|
function fillTemplate(template, parts) {
|
|
4882
|
-
return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
5240
|
+
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);
|
|
4883
5241
|
}
|
|
4884
5242
|
/**
|
|
4885
5243
|
* Resolve the compiled entry for a manifest definition, asserting the router
|
|
@@ -4928,29 +5286,44 @@ async function generateParameterSets(definition, locale, ctx) {
|
|
|
4928
5286
|
* locale). The generate context is the spec `{ locale, require, has }`, so a
|
|
4929
5287
|
* `.generate()` handler pulls sibling APIs the spec way.
|
|
4930
5288
|
*
|
|
5289
|
+
* Instances are deduplicated by resolved output file: a route whose pattern has no
|
|
5290
|
+
* lang placeholder (or whose `generate()` params omit `lang`) resolves to the SAME
|
|
5291
|
+
* `toFile` path for EVERY locale — without the guard each locale's render races on
|
|
5292
|
+
* one output file and the shipped HTML's locale is nondeterministic. The default
|
|
5293
|
+
* locale is expanded FIRST, so a collapsed route keeps its default-locale instance.
|
|
5294
|
+
*
|
|
4931
5295
|
* @param definition - The route definition from the manifest.
|
|
4932
5296
|
* @param locales - Active locale codes from i18n.
|
|
5297
|
+
* @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
|
|
4933
5298
|
* @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
|
|
4934
5299
|
* @param ctx - Plugin context (provides `require`/`has` for the generate context).
|
|
4935
|
-
* @returns The flattened list of page instances for this route.
|
|
5300
|
+
* @returns The flattened, file-deduplicated list of page instances for this route.
|
|
4936
5301
|
* @example
|
|
4937
5302
|
* ```ts
|
|
4938
|
-
* await expandRoute(def, ["en"], byPattern, ctx);
|
|
5303
|
+
* await expandRoute(def, ["en"], "en", byPattern, ctx);
|
|
4939
5304
|
* ```
|
|
4940
5305
|
*/
|
|
4941
|
-
async function expandRoute(definition, locales, byPattern, ctx) {
|
|
5306
|
+
async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
|
|
4942
5307
|
const entry = resolveEntry(byPattern, definition);
|
|
4943
5308
|
const { name } = entry;
|
|
5309
|
+
const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
|
|
4944
5310
|
const instances = [];
|
|
4945
|
-
|
|
5311
|
+
const claimedFiles = /* @__PURE__ */ new Set();
|
|
5312
|
+
for (const locale of orderedLocales) {
|
|
4946
5313
|
const parameterSets = await generateParameterSets(definition, locale, ctx);
|
|
4947
|
-
for (const raw of parameterSets)
|
|
4948
|
-
|
|
4949
|
-
entry
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
5314
|
+
for (const raw of parameterSets) {
|
|
5315
|
+
const params = raw ?? {};
|
|
5316
|
+
const file = entry.toFile(params);
|
|
5317
|
+
if (claimedFiles.has(file)) continue;
|
|
5318
|
+
claimedFiles.add(file);
|
|
5319
|
+
instances.push({
|
|
5320
|
+
definition,
|
|
5321
|
+
entry,
|
|
5322
|
+
name,
|
|
5323
|
+
params,
|
|
5324
|
+
locale
|
|
5325
|
+
});
|
|
5326
|
+
}
|
|
4954
5327
|
}
|
|
4955
5328
|
return instances;
|
|
4956
5329
|
}
|
|
@@ -5215,6 +5588,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
|
|
|
5215
5588
|
head: composeHeadHtml(ctx, instance, url, routeContext, data),
|
|
5216
5589
|
body: renderBodyCached(ctx, instance, routeContext, data, reuse),
|
|
5217
5590
|
assets: shell.assets,
|
|
5591
|
+
assetsCss: shell.assetsCss,
|
|
5592
|
+
assetsJs: shell.assetsJs,
|
|
5218
5593
|
locale
|
|
5219
5594
|
};
|
|
5220
5595
|
const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
|
|
@@ -5254,26 +5629,30 @@ async function prepareShell(ctx) {
|
|
|
5254
5629
|
const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
|
|
5255
5630
|
return {
|
|
5256
5631
|
assets: buildAssetTags(ctx),
|
|
5632
|
+
assetsCss: buildAssetTags(ctx, "css"),
|
|
5633
|
+
assetsJs: buildAssetTags(ctx, "js"),
|
|
5257
5634
|
template,
|
|
5258
5635
|
defaultLocale: ctx.require(i18nPlugin).defaultLocale()
|
|
5259
5636
|
};
|
|
5260
5637
|
}
|
|
5261
5638
|
/**
|
|
5262
5639
|
* Expand every manifest route into its concrete page instances across all locales
|
|
5263
|
-
* (delegating per-route expansion
|
|
5640
|
+
* (delegating per-route expansion — and per-route output-file deduplication — to
|
|
5641
|
+
* {@link expandRoute}) and flatten the result.
|
|
5264
5642
|
*
|
|
5265
5643
|
* @param manifest - The route definitions from `router.manifest()`.
|
|
5266
5644
|
* @param locales - Active locale codes from i18n.
|
|
5645
|
+
* @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
|
|
5267
5646
|
* @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
|
|
5268
5647
|
* @param ctx - Plugin context (provides `require`/`has` for generate contexts).
|
|
5269
5648
|
* @returns The flattened list of page instances to render.
|
|
5270
5649
|
* @example
|
|
5271
5650
|
* ```ts
|
|
5272
|
-
* const instances = await expandAllInstances(manifest, ["en"], byPattern, ctx);
|
|
5651
|
+
* const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
|
|
5273
5652
|
* ```
|
|
5274
5653
|
*/
|
|
5275
|
-
async function expandAllInstances(manifest, locales, byPattern, ctx) {
|
|
5276
|
-
return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern, ctx)))).flat();
|
|
5654
|
+
async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
|
|
5655
|
+
return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
|
|
5277
5656
|
}
|
|
5278
5657
|
/**
|
|
5279
5658
|
* Persist per-page client-data sidecars when the app opts into client navigation
|
|
@@ -5389,7 +5768,7 @@ async function renderPages(ctx, options) {
|
|
|
5389
5768
|
const byPattern = makeEntryMap(router);
|
|
5390
5769
|
if (!reuse) ctx.state.renderCache.clear();
|
|
5391
5770
|
const shell = await prepareShell(ctx);
|
|
5392
|
-
const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
|
|
5771
|
+
const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
|
|
5393
5772
|
await writeDataSidecars(ctx, rendered, router.mode());
|
|
5394
5773
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
5395
5774
|
return {
|
|
@@ -5397,37 +5776,6 @@ async function renderPages(ctx, options) {
|
|
|
5397
5776
|
rootHtml: findRootHtml(rendered)
|
|
5398
5777
|
};
|
|
5399
5778
|
}
|
|
5400
|
-
/**
|
|
5401
|
-
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
5402
|
-
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
5403
|
-
* the source directory does not exist.
|
|
5404
|
-
*
|
|
5405
|
-
* @param ctx - Plugin context (provides `config`, `log`).
|
|
5406
|
-
* @returns The copy result, or `null` when the public directory is absent.
|
|
5407
|
-
* @example
|
|
5408
|
-
* ```ts
|
|
5409
|
-
* const result = await copyPublic(ctx);
|
|
5410
|
-
* ```
|
|
5411
|
-
*/
|
|
5412
|
-
async function copyPublic(ctx) {
|
|
5413
|
-
const from = ctx.config.publicDir ?? "public";
|
|
5414
|
-
if (!(0, node_fs.existsSync)(from)) {
|
|
5415
|
-
ctx.log.debug("build:public", {
|
|
5416
|
-
skipped: true,
|
|
5417
|
-
from
|
|
5418
|
-
});
|
|
5419
|
-
return null;
|
|
5420
|
-
}
|
|
5421
|
-
await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
|
|
5422
|
-
ctx.log.debug("build:public", {
|
|
5423
|
-
from,
|
|
5424
|
-
dest: ctx.config.outDir
|
|
5425
|
-
});
|
|
5426
|
-
return {
|
|
5427
|
-
from: node_path$1.default.normalize(from),
|
|
5428
|
-
copied: 1
|
|
5429
|
-
};
|
|
5430
|
-
}
|
|
5431
5779
|
//#endregion
|
|
5432
5780
|
//#region src/plugins/build/phases/sitemap.ts
|
|
5433
5781
|
/**
|
|
@@ -5464,7 +5812,22 @@ async function expandUrls(definition, entry, locales, ctx) {
|
|
|
5464
5812
|
return urls;
|
|
5465
5813
|
}
|
|
5466
5814
|
/**
|
|
5467
|
-
*
|
|
5815
|
+
* XML-escape a value for safe insertion into a text node (`& < > " '`). `&` is
|
|
5816
|
+
* escaped first so already-escaped entities are not double-escaped.
|
|
5817
|
+
*
|
|
5818
|
+
* @param raw - The unsafe string.
|
|
5819
|
+
* @returns The XML-escaped string.
|
|
5820
|
+
* @example
|
|
5821
|
+
* ```ts
|
|
5822
|
+
* escapeXml("https://blog.dev/a&b/"); // "https://blog.dev/a&b/"
|
|
5823
|
+
* ```
|
|
5824
|
+
*/
|
|
5825
|
+
function escapeXml(raw) {
|
|
5826
|
+
return raw.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
5827
|
+
}
|
|
5828
|
+
/**
|
|
5829
|
+
* Serialize a `<urlset>` sitemap document from a canonical URL set. Each `<loc>`
|
|
5830
|
+
* value is XML-escaped so slugs containing `&`/`<`/`>` cannot break the document.
|
|
5468
5831
|
*
|
|
5469
5832
|
* @param urls - The canonical (absolute) URLs.
|
|
5470
5833
|
* @returns The serialized sitemap XML.
|
|
@@ -5474,7 +5837,7 @@ async function expandUrls(definition, entry, locales, ctx) {
|
|
|
5474
5837
|
* ```
|
|
5475
5838
|
*/
|
|
5476
5839
|
function serializeSitemap(urls) {
|
|
5477
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((url) => ` <url><loc>${url}</loc></url>`).join("\n")}\n</urlset>\n`;
|
|
5840
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`).join("\n")}\n</urlset>\n`;
|
|
5478
5841
|
}
|
|
5479
5842
|
/**
|
|
5480
5843
|
* Index the compiled router entries by their URL pattern, so each manifest
|
|
@@ -5580,7 +5943,8 @@ async function generateSitemap(ctx) {
|
|
|
5580
5943
|
const locales = ctx.require(i18nPlugin).locales();
|
|
5581
5944
|
const router = ctx.require(routerPlugin);
|
|
5582
5945
|
const byPattern = indexRoutesByPattern(router);
|
|
5583
|
-
const
|
|
5946
|
+
const relativeUrls = await collectRelativeUrls(router.manifest(), byPattern, locales, ctx);
|
|
5947
|
+
const urls = [...new Set(relativeUrls)].map((relative) => site.canonical(relative));
|
|
5584
5948
|
const xml = serializeSitemap(urls);
|
|
5585
5949
|
const robots = buildRobotsTxt(site);
|
|
5586
5950
|
await writeSitemapFiles(ctx.config.outDir, xml, robots);
|
|
@@ -5597,6 +5961,8 @@ async function generateSitemap(ctx) {
|
|
|
5597
5961
|
* @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
|
|
5598
5962
|
* emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
|
|
5599
5963
|
*/
|
|
5964
|
+
/** Error prefix for build pipeline runtime failures (spec/11 Part-3). */
|
|
5965
|
+
const ERROR_PREFIX$8 = "[web] build";
|
|
5600
5966
|
/** Matches a Markdown source path (a content edit). */
|
|
5601
5967
|
const MARKDOWN_PATH = /\.md$/;
|
|
5602
5968
|
/** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
|
|
@@ -5632,6 +5998,46 @@ function planIncrementalRebuild(changed) {
|
|
|
5632
5998
|
};
|
|
5633
5999
|
}
|
|
5634
6000
|
/**
|
|
6001
|
+
* Test whether a resolved path sits STRICTLY inside a resolved base directory —
|
|
6002
|
+
* equality does not count (the base itself is never "inside" itself).
|
|
6003
|
+
*
|
|
6004
|
+
* @param resolved - The resolved absolute candidate path.
|
|
6005
|
+
* @param baseResolved - The resolved absolute base directory.
|
|
6006
|
+
* @returns `true` when `resolved` is nested beneath `baseResolved`.
|
|
6007
|
+
* @example
|
|
6008
|
+
* ```ts
|
|
6009
|
+
* isStrictlyInside("/app/dist", "/app"); // true — but isStrictlyInside("/app", "/app") is false
|
|
6010
|
+
* ```
|
|
6011
|
+
*/
|
|
6012
|
+
function isStrictlyInside(resolved, baseResolved) {
|
|
6013
|
+
return resolved !== baseResolved && resolved.startsWith(baseResolved + node_path$1.default.sep);
|
|
6014
|
+
}
|
|
6015
|
+
/**
|
|
6016
|
+
* Assert that `outDir` is a SAFE target for the clean phase's recursive force-delete,
|
|
6017
|
+
* defending against a misconfiguration (`outDir: "/"`, `"."`, `"~"`, a `..` escape)
|
|
6018
|
+
* that would otherwise wipe the filesystem root, the home directory, or the project
|
|
6019
|
+
* itself. Mirrors the deploy plugin's `assertWithinRoot` posture, tightened for
|
|
6020
|
+
* deletion: a target is safe only when it sits STRICTLY inside the project root
|
|
6021
|
+
* (never the root itself) or strictly inside the OS temp directory (a disposable
|
|
6022
|
+
* area, used by preview/test builds) — and is never the home directory.
|
|
6023
|
+
*
|
|
6024
|
+
* @param outDir - The configured output directory (relative or absolute).
|
|
6025
|
+
* @param root - The absolute project root relative paths resolve against.
|
|
6026
|
+
* @returns The resolved absolute output directory.
|
|
6027
|
+
* @throws {Error} `[web] build.outDir` when the resolved target is unsafe to delete.
|
|
6028
|
+
* @example
|
|
6029
|
+
* ```ts
|
|
6030
|
+
* assertSafeCleanTarget("./dist", process.cwd()); // "<cwd>/dist"
|
|
6031
|
+
* ```
|
|
6032
|
+
*/
|
|
6033
|
+
function assertSafeCleanTarget(outDir, root) {
|
|
6034
|
+
const resolved = node_path$1.default.isAbsolute(outDir) ? node_path$1.default.resolve(outDir) : node_path$1.default.resolve(root, outDir);
|
|
6035
|
+
const rootResolved = node_path$1.default.resolve(root);
|
|
6036
|
+
const isHome = resolved === node_path$1.default.resolve((0, node_os.homedir)());
|
|
6037
|
+
if ((isStrictlyInside(resolved, rootResolved) || isStrictlyInside(resolved, node_path$1.default.resolve((0, node_os.tmpdir)()))) && !isHome) return resolved;
|
|
6038
|
+
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".`);
|
|
6039
|
+
}
|
|
6040
|
+
/**
|
|
5635
6041
|
* The static ordered list of pipeline phase names.
|
|
5636
6042
|
*
|
|
5637
6043
|
* @example
|
|
@@ -5651,6 +6057,7 @@ const PHASE_ORDER = [
|
|
|
5651
6057
|
"public",
|
|
5652
6058
|
"not-found",
|
|
5653
6059
|
"locale-redirects",
|
|
6060
|
+
"cache-headers",
|
|
5654
6061
|
"root-index"
|
|
5655
6062
|
];
|
|
5656
6063
|
/**
|
|
@@ -5681,17 +6088,23 @@ async function withPhase(ctx, phase, work) {
|
|
|
5681
6088
|
}
|
|
5682
6089
|
/**
|
|
5683
6090
|
* Reset the per-run state (manifest, buildCache, runId) and assign a fresh runId.
|
|
6091
|
+
* A clean run (no `skipClean`) also drops the OG image hash cache: the outDir wipe
|
|
6092
|
+
* deletes every `og/<slug>.png` the cache indexes, so honoring those warm entries
|
|
6093
|
+
* would skip rendering files that no longer exist. A `skipClean` (dev) run keeps
|
|
6094
|
+
* the cache — its PNGs survive on disk.
|
|
5684
6095
|
*
|
|
5685
6096
|
* @param ctx - The phase context whose `state` is reset.
|
|
6097
|
+
* @param options - The run options (only `skipClean` is consulted).
|
|
5686
6098
|
* @example
|
|
5687
6099
|
* ```ts
|
|
5688
|
-
* resetRun(ctx);
|
|
6100
|
+
* resetRun(ctx, options);
|
|
5689
6101
|
* ```
|
|
5690
6102
|
*/
|
|
5691
|
-
function resetRun(ctx) {
|
|
6103
|
+
function resetRun(ctx, options) {
|
|
5692
6104
|
ctx.state.manifest = null;
|
|
5693
6105
|
ctx.state.buildCache = /* @__PURE__ */ new Map();
|
|
5694
6106
|
ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
6107
|
+
if (!options?.skipClean) ctx.state.ogImageHashCache.clear();
|
|
5695
6108
|
}
|
|
5696
6109
|
/**
|
|
5697
6110
|
* Report each rejected outcome from a settled output batch as a `build:outputs`
|
|
@@ -5739,7 +6152,7 @@ async function runOutputs(ctx) {
|
|
|
5739
6152
|
}
|
|
5740
6153
|
/**
|
|
5741
6154
|
* Executes the full SSG pipeline for one run: clean → bundle → content/images →
|
|
5742
|
-
* pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
|
|
6155
|
+
* pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
|
|
5743
6156
|
* and `Promise.all` only — never inlines dependency domain logic. Emits a
|
|
5744
6157
|
* `build:phase` boundary per phase and `build:complete` once at the end.
|
|
5745
6158
|
*
|
|
@@ -5753,7 +6166,7 @@ async function runOutputs(ctx) {
|
|
|
5753
6166
|
*/
|
|
5754
6167
|
async function runPipeline(ctx, options) {
|
|
5755
6168
|
const started = Date.now();
|
|
5756
|
-
resetRun(ctx);
|
|
6169
|
+
resetRun(ctx, options);
|
|
5757
6170
|
const outDir = options?.outDir ?? ctx.config.outDir;
|
|
5758
6171
|
const phaseContext = {
|
|
5759
6172
|
...ctx,
|
|
@@ -5764,10 +6177,13 @@ async function runPipeline(ctx, options) {
|
|
|
5764
6177
|
}
|
|
5765
6178
|
};
|
|
5766
6179
|
const plan = planIncrementalRebuild(options?.changed);
|
|
5767
|
-
if (!options?.skipClean)
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
6180
|
+
if (!options?.skipClean) {
|
|
6181
|
+
assertSafeCleanTarget(outDir, process.cwd());
|
|
6182
|
+
await (0, node_fs_promises.rm)(outDir, {
|
|
6183
|
+
recursive: true,
|
|
6184
|
+
force: true
|
|
6185
|
+
});
|
|
6186
|
+
}
|
|
5771
6187
|
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
5772
6188
|
await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
|
|
5773
6189
|
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
|
|
@@ -5777,6 +6193,7 @@ async function runPipeline(ctx, options) {
|
|
|
5777
6193
|
const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
|
|
5778
6194
|
await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
|
|
5779
6195
|
await runOutputs(phaseContext);
|
|
6196
|
+
if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
|
|
5780
6197
|
await withPhase(phaseContext, "root-index", async () => {
|
|
5781
6198
|
if (pages.rootHtml !== null) await (0, node_fs_promises.writeFile)(node_path$1.default.join(outDir, "index.html"), pages.rootHtml, "utf8");
|
|
5782
6199
|
});
|
|
@@ -10063,6 +10480,23 @@ function isInternalLink(url) {
|
|
|
10063
10480
|
return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
|
|
10064
10481
|
}
|
|
10065
10482
|
/**
|
|
10483
|
+
* The navigable path of a URL or Location: pathname plus query string. The query
|
|
10484
|
+
* is part of page identity (the kernel's `currentUrl` is pathname + search), so
|
|
10485
|
+
* same-page checks, history entries, fetches, and scroll keys must all carry it —
|
|
10486
|
+
* comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
|
|
10487
|
+
* and the History fallback would drop the query from the address bar.
|
|
10488
|
+
*
|
|
10489
|
+
* @param target - The URL or Location to read.
|
|
10490
|
+
* @param target.pathname - The path component.
|
|
10491
|
+
* @param target.search - The query-string component (`""` when absent).
|
|
10492
|
+
* @returns The pathname + search string.
|
|
10493
|
+
* @example
|
|
10494
|
+
* pathWithSearch(new URL("https://x.dev/search?q=a")); // "/search?q=a"
|
|
10495
|
+
*/
|
|
10496
|
+
function pathWithSearch(target) {
|
|
10497
|
+
return target.pathname + target.search;
|
|
10498
|
+
}
|
|
10499
|
+
/**
|
|
10066
10500
|
* Save the current scroll position keyed by path (best-effort; ignores storage errors).
|
|
10067
10501
|
*
|
|
10068
10502
|
* @param path - The path to key the scroll position under.
|
|
@@ -10091,19 +10525,27 @@ function restoreScrollPosition(path) {
|
|
|
10091
10525
|
* Fetch a page and hand its HTML to the handlers; on any error fall back to a
|
|
10092
10526
|
* full browser navigation (`location.href = pathname`).
|
|
10093
10527
|
*
|
|
10528
|
+
* When `signal` aborts (this navigation was superseded by a newer one) the
|
|
10529
|
+
* fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
|
|
10530
|
+
* reload — the live navigation owns the document from that point on.
|
|
10531
|
+
*
|
|
10094
10532
|
* @param pathname - The destination pathname.
|
|
10095
10533
|
* @param handlers - The navigation lifecycle callbacks.
|
|
10534
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10096
10535
|
* @returns A promise that resolves once the swap (or fallback) is dispatched.
|
|
10097
10536
|
* @example
|
|
10098
|
-
* await performNavigation("/about", handlers);
|
|
10537
|
+
* await performNavigation("/about", handlers, navEvent.signal);
|
|
10099
10538
|
*/
|
|
10100
|
-
async function performNavigation(pathname, handlers) {
|
|
10539
|
+
async function performNavigation(pathname, handlers, signal) {
|
|
10101
10540
|
handlers.onStart(pathname);
|
|
10102
10541
|
try {
|
|
10103
|
-
const response = await fetch(pathname);
|
|
10542
|
+
const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
|
|
10104
10543
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
|
10105
|
-
|
|
10544
|
+
const html = await response.text();
|
|
10545
|
+
if (signal?.aborted) return;
|
|
10546
|
+
handlers.onEnd(html, pathname);
|
|
10106
10547
|
} catch {
|
|
10548
|
+
if (signal?.aborted) return;
|
|
10107
10549
|
handlers.onError();
|
|
10108
10550
|
location.href = pathname;
|
|
10109
10551
|
}
|
|
@@ -10142,23 +10584,29 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
|
|
|
10142
10584
|
* inside the same transition frame (after the DOM mutation) so component
|
|
10143
10585
|
* re-mounting is captured by the transition snapshot.
|
|
10144
10586
|
*
|
|
10587
|
+
* Returns whether the swap was dispatched: `false` when either document lacks
|
|
10588
|
+
* the `swapSelector` region, so the caller can fall back to a full navigation
|
|
10589
|
+
* instead of finishing the SPA nav against an un-swapped body.
|
|
10590
|
+
*
|
|
10145
10591
|
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
10146
10592
|
* @param swapSelector - CSS selector for the region to replace.
|
|
10147
10593
|
* @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
|
|
10148
10594
|
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
10149
10595
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10150
10596
|
* (forwarded to {@link runSwap} — e.g. scroll to the destination position).
|
|
10597
|
+
* @returns `true` when the swap was dispatched, `false` when either document lacks the region.
|
|
10151
10598
|
* @example
|
|
10152
10599
|
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
10153
10600
|
*/
|
|
10154
10601
|
function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
|
|
10155
10602
|
const newContent = doc.querySelector(swapSelector);
|
|
10156
10603
|
const currentContent = document.querySelector(swapSelector);
|
|
10157
|
-
if (!newContent || !currentContent) return;
|
|
10604
|
+
if (!newContent || !currentContent) return false;
|
|
10158
10605
|
runSwap(() => {
|
|
10159
10606
|
currentContent.replaceWith(newContent);
|
|
10160
10607
|
onSwapped();
|
|
10161
10608
|
}, viewTransitions, beforeCapture);
|
|
10609
|
+
return true;
|
|
10162
10610
|
}
|
|
10163
10611
|
/**
|
|
10164
10612
|
* Resolve a navigable internal URL from a click event, or `undefined` when the
|
|
@@ -10192,7 +10640,20 @@ function resolveClickTarget(event) {
|
|
|
10192
10640
|
* @example
|
|
10193
10641
|
* const dispose = attachHistoryFallback(handlers);
|
|
10194
10642
|
*/
|
|
10195
|
-
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
10643
|
+
function attachHistoryFallback(handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
|
|
10644
|
+
let controller;
|
|
10645
|
+
/**
|
|
10646
|
+
* Supersede the in-flight navigation (if any) and mint the next one's abort signal.
|
|
10647
|
+
*
|
|
10648
|
+
* @returns The fresh navigation's abort signal.
|
|
10649
|
+
* @example
|
|
10650
|
+
* const signal = supersede();
|
|
10651
|
+
*/
|
|
10652
|
+
const supersede = () => {
|
|
10653
|
+
controller?.abort();
|
|
10654
|
+
controller = new AbortController();
|
|
10655
|
+
return controller.signal;
|
|
10656
|
+
};
|
|
10196
10657
|
/**
|
|
10197
10658
|
* Intercept an internal-link click and run a History-API navigation.
|
|
10198
10659
|
*
|
|
@@ -10203,17 +10664,18 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10203
10664
|
const onClick = (event) => {
|
|
10204
10665
|
const url = resolveClickTarget(event);
|
|
10205
10666
|
if (!url) return;
|
|
10667
|
+
if (url.pathname === location.pathname && url.hash) return;
|
|
10206
10668
|
event.preventDefault();
|
|
10207
|
-
if (url
|
|
10669
|
+
if (pathWithSearch(url) === pathWithSearch(location)) {
|
|
10208
10670
|
window.scrollTo({
|
|
10209
10671
|
top: 0,
|
|
10210
10672
|
behavior: "smooth"
|
|
10211
10673
|
});
|
|
10212
10674
|
return;
|
|
10213
10675
|
}
|
|
10214
|
-
saveScrollPosition(location
|
|
10215
|
-
history.pushState({ scrollY: 0 }, "", url
|
|
10216
|
-
navigate(url
|
|
10676
|
+
saveScrollPosition(pathWithSearch(location));
|
|
10677
|
+
history.pushState({ scrollY: 0 }, "", pathWithSearch(url));
|
|
10678
|
+
navigate(pathWithSearch(url), true, supersede()).catch(() => {});
|
|
10217
10679
|
};
|
|
10218
10680
|
/**
|
|
10219
10681
|
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
@@ -10222,7 +10684,11 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10222
10684
|
* globalThis.addEventListener("popstate", onPopState);
|
|
10223
10685
|
*/
|
|
10224
10686
|
const onPopState = () => {
|
|
10225
|
-
|
|
10687
|
+
const path = pathWithSearch(location);
|
|
10688
|
+
const signal = supersede();
|
|
10689
|
+
navigate(path, false, signal).then(() => {
|
|
10690
|
+
if (!signal.aborted) restoreScrollPosition(path);
|
|
10691
|
+
}).catch(() => {});
|
|
10226
10692
|
};
|
|
10227
10693
|
document.addEventListener("click", onClick);
|
|
10228
10694
|
globalThis.addEventListener("popstate", onPopState);
|
|
@@ -10241,7 +10707,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
|
|
|
10241
10707
|
* @example
|
|
10242
10708
|
* const dispose = attachNavigationApi(navigation, handlers);
|
|
10243
10709
|
*/
|
|
10244
|
-
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
10710
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname, _scrollToTop, signal) => performNavigation(pathname, handlers, signal)) {
|
|
10245
10711
|
/**
|
|
10246
10712
|
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
10247
10713
|
*
|
|
@@ -10253,7 +10719,7 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
|
|
|
10253
10719
|
const url = new URL(navEvent.destination.url);
|
|
10254
10720
|
if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
|
|
10255
10721
|
if (!isInternalLink(url)) return;
|
|
10256
|
-
if (url
|
|
10722
|
+
if (pathWithSearch(url) === pathWithSearch(location)) {
|
|
10257
10723
|
navEvent.intercept({ handler: () => {
|
|
10258
10724
|
window.scrollTo({
|
|
10259
10725
|
top: 0,
|
|
@@ -10267,9 +10733,9 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
|
|
|
10267
10733
|
scroll: "manual",
|
|
10268
10734
|
handler: async () => {
|
|
10269
10735
|
if (navEvent.navigationType === "traverse") {
|
|
10270
|
-
await navigate(url
|
|
10271
|
-
navEvent.scroll();
|
|
10272
|
-
} else await navigate(url.
|
|
10736
|
+
await navigate(pathWithSearch(url), false, navEvent.signal);
|
|
10737
|
+
if (!navEvent.signal.aborted) navEvent.scroll();
|
|
10738
|
+
} else await navigate(pathWithSearch(url), true, navEvent.signal);
|
|
10273
10739
|
}
|
|
10274
10740
|
});
|
|
10275
10741
|
};
|
|
@@ -10455,6 +10921,11 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10455
10921
|
};
|
|
10456
10922
|
/**
|
|
10457
10923
|
* Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
|
|
10924
|
+
* When the region cannot be swapped (either document lacks the swap selector)
|
|
10925
|
+
* the SPA nav cannot complete — the head is already synced and the islands torn
|
|
10926
|
+
* down, so finishing would leave the OLD body under a NEW URL with a `spa:navigated`
|
|
10927
|
+
* claiming success. Fall back to a full browser navigation instead (mirroring
|
|
10928
|
+
* {@link performNavigation}'s fetch-error fallback).
|
|
10458
10929
|
*
|
|
10459
10930
|
* @param html - The fetched page HTML.
|
|
10460
10931
|
* @param pathname - The destination pathname.
|
|
@@ -10465,10 +10936,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10465
10936
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
10466
10937
|
syncHead(deps.head, doc);
|
|
10467
10938
|
unmountPageSpecific(state, emit);
|
|
10468
|
-
swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10939
|
+
if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10469
10940
|
scanAndMount(state, emit, resolved.swapSelector);
|
|
10470
10941
|
notifyNavEnd(state);
|
|
10471
|
-
}, applyPendingScroll)
|
|
10942
|
+
}, applyPendingScroll)) {
|
|
10943
|
+
handleError();
|
|
10944
|
+
location.href = pathname;
|
|
10945
|
+
return;
|
|
10946
|
+
}
|
|
10472
10947
|
state.currentUrl = pathname;
|
|
10473
10948
|
progress?.done();
|
|
10474
10949
|
emit("spa:navigated", { url: pathname });
|
|
@@ -10548,13 +11023,16 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10548
11023
|
*
|
|
10549
11024
|
* @param pathname - The destination pathname (recorded as the new current URL).
|
|
10550
11025
|
* @param resolvedRender - The inputs produced by {@link resolveDataRender}.
|
|
11026
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10551
11027
|
* @example
|
|
10552
11028
|
* await commitDataRender("/en/world/", resolved);
|
|
10553
11029
|
*/
|
|
10554
|
-
const commitDataRender = async (pathname, resolvedRender) => {
|
|
11030
|
+
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
11031
|
+
if (signal?.aborted) return;
|
|
10555
11032
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10556
11033
|
handleStart(pathname);
|
|
10557
11034
|
const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
|
|
11035
|
+
if (signal?.aborted) return;
|
|
10558
11036
|
syncDataHead(route, routeContext);
|
|
10559
11037
|
unmountPageSpecific(state, emit);
|
|
10560
11038
|
/**
|
|
@@ -10586,15 +11064,16 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10586
11064
|
* to HTML-over-fetch.
|
|
10587
11065
|
*
|
|
10588
11066
|
* @param pathname - The destination pathname (search stripped for matching).
|
|
11067
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
|
|
10589
11068
|
* @returns `true` if the route was rendered from its data, else `false`.
|
|
10590
11069
|
* @example
|
|
10591
11070
|
* if (await tryDataRender("/en/world/")) return;
|
|
10592
11071
|
*/
|
|
10593
|
-
const tryDataRender = async (pathname) => {
|
|
11072
|
+
const tryDataRender = async (pathname, signal) => {
|
|
10594
11073
|
try {
|
|
10595
11074
|
const resolvedRender = await resolveDataRender(pathname);
|
|
10596
11075
|
if (resolvedRender === false) return false;
|
|
10597
|
-
await commitDataRender(pathname, resolvedRender);
|
|
11076
|
+
await commitDataRender(pathname, resolvedRender, signal);
|
|
10598
11077
|
return true;
|
|
10599
11078
|
} catch {
|
|
10600
11079
|
progress?.done();
|
|
@@ -10610,14 +11089,17 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10610
11089
|
* @param pathname - The destination pathname.
|
|
10611
11090
|
* @param scrollToTop - Whether the swap should scroll to top before its snapshot
|
|
10612
11091
|
* (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
|
|
11092
|
+
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
|
|
11093
|
+
* a superseded navigation never applies its swap (no stale last-write-wins).
|
|
10613
11094
|
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
10614
11095
|
* @example
|
|
10615
11096
|
* await navigate("/en/world/");
|
|
10616
11097
|
*/
|
|
10617
|
-
const navigate = async (pathname, scrollToTop = true) => {
|
|
11098
|
+
const navigate = async (pathname, scrollToTop = true, signal) => {
|
|
10618
11099
|
pendingScrollToTop = scrollToTop;
|
|
10619
|
-
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
10620
|
-
|
|
11100
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
|
|
11101
|
+
if (signal?.aborted) return;
|
|
11102
|
+
await performNavigation(pathname, handlers, signal);
|
|
10621
11103
|
};
|
|
10622
11104
|
return {
|
|
10623
11105
|
/**
|
|
@@ -11218,9 +11700,12 @@ function defaultRehypePlugins() {
|
|
|
11218
11700
|
* Clones the library default and additively allowlists the markup our custom
|
|
11219
11701
|
* transforms emit: `class` values (`pull-quote`, `section-divider`,
|
|
11220
11702
|
* `section-divider-ornament`) on `aside`/`div`/`span`, and the `loading`
|
|
11221
|
-
* attribute on `img`. `class`/`className
|
|
11222
|
-
*
|
|
11223
|
-
*
|
|
11703
|
+
* attribute on `img`. `class`/`className` are allowlisted globally (`*`, i.e.
|
|
11704
|
+
* on every element) so Shiki's class hooks survive the sanitize pass. `style`
|
|
11705
|
+
* is deliberately NOT global — CSS values are not sanitized, so a global
|
|
11706
|
+
* `style` allowlist would let untrusted content run overlay/exfiltration
|
|
11707
|
+
* styling; it is allowed only on `pre`/`code`, where Shiki places its
|
|
11708
|
+
* block-level theme background/foreground.
|
|
11224
11709
|
*
|
|
11225
11710
|
* @returns The extended, security-hardened sanitize schema.
|
|
11226
11711
|
* @example
|
|
@@ -11251,8 +11736,7 @@ function buildSanitizeSchema() {
|
|
|
11251
11736
|
"*": [
|
|
11252
11737
|
...baseAttributes["*"] ?? [],
|
|
11253
11738
|
"className",
|
|
11254
|
-
"class"
|
|
11255
|
-
"style"
|
|
11739
|
+
"class"
|
|
11256
11740
|
],
|
|
11257
11741
|
aside: [
|
|
11258
11742
|
...baseAttributes.aside ?? [],
|