@t09tanaka/stoneage 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +768 -0
- package/dist/agent-skill.d.ts +1 -0
- package/dist/agent-skill.js +140 -0
- package/dist/assets.d.ts +125 -0
- package/dist/assets.js +341 -0
- package/dist/cli.d.ts +236 -0
- package/dist/cli.js +3077 -0
- package/dist/core.d.ts +473 -0
- package/dist/core.js +2897 -0
- package/dist/data.d.ts +121 -0
- package/dist/data.js +358 -0
- package/dist/deploy.d.ts +34 -0
- package/dist/deploy.js +203 -0
- package/dist/dev.d.ts +36 -0
- package/dist/dev.js +313 -0
- package/dist/example.d.ts +134 -0
- package/dist/example.js +1272 -0
- package/dist/fragment/client.d.ts +13 -0
- package/dist/fragment/client.js +150 -0
- package/dist/fragment.d.ts +15 -0
- package/dist/fragment.js +27 -0
- package/dist/html.d.ts +57 -0
- package/dist/html.js +208 -0
- package/dist/images.d.ts +95 -0
- package/dist/images.js +292 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migration.d.ts +157 -0
- package/dist/migration.js +983 -0
- package/dist/optimize.d.ts +15 -0
- package/dist/optimize.js +215 -0
- package/dist/pagination.d.ts +26 -0
- package/dist/pagination.js +62 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +10 -0
- package/dist/search.d.ts +32 -0
- package/dist/search.js +55 -0
- package/dist/testing.d.ts +66 -0
- package/dist/testing.js +97 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +1 -0
- package/jsx-loader.mjs +52 -0
- package/jsx-register.mjs +7 -0
- package/package.json +135 -0
- package/tsconfig.base.json +16 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,2897 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix } from "node:path";
|
|
4
|
+
import { copyPublicAssets, expandPublicDir, resolveClientAssets, } from "./assets.js";
|
|
5
|
+
import { withDeferredFragmentDefaults, } from "./fragment.js";
|
|
6
|
+
import { renderNetlifyHeaders, renderNetlifyRedirects } from "./deploy.js";
|
|
7
|
+
import { publicAssetOutputPath as publicOutputPathForAsset } from "./paths.js";
|
|
8
|
+
export * from "./data.js";
|
|
9
|
+
const validationConfigKeys = ["budgets", "requiredMetadata", "requirePrecompressed"];
|
|
10
|
+
const requiredMetadataKeys = [
|
|
11
|
+
"title",
|
|
12
|
+
"description",
|
|
13
|
+
"canonical",
|
|
14
|
+
"favicon",
|
|
15
|
+
"ogImage",
|
|
16
|
+
"twitterImage",
|
|
17
|
+
];
|
|
18
|
+
const requiredMetadataKeyList = requiredMetadataKeys.join(", ");
|
|
19
|
+
const numericBudgetConfigKeys = [
|
|
20
|
+
"maxPages",
|
|
21
|
+
"maxArtifacts",
|
|
22
|
+
"maxHtmlBytes",
|
|
23
|
+
"maxCssBytes",
|
|
24
|
+
"maxJsBytes",
|
|
25
|
+
"maxPublicFileBytes",
|
|
26
|
+
"maxTotalPublicBytes",
|
|
27
|
+
"maxImageBytes",
|
|
28
|
+
"maxBrotliBytes",
|
|
29
|
+
"maxGzipBytes",
|
|
30
|
+
"maxDependencyFanout",
|
|
31
|
+
];
|
|
32
|
+
const budgetConfigKeys = [
|
|
33
|
+
...numericBudgetConfigKeys,
|
|
34
|
+
"maxRouteFamilyPages",
|
|
35
|
+
"maxArtifactFamilyOutputs",
|
|
36
|
+
];
|
|
37
|
+
const manifestReadCache = new Map();
|
|
38
|
+
const buildReportReadCache = new Map();
|
|
39
|
+
export async function loadValidationConfig(path) {
|
|
40
|
+
const raw = await readFile(path, "utf8");
|
|
41
|
+
return {
|
|
42
|
+
configPath: path,
|
|
43
|
+
configHash: createHash("sha256").update(raw).digest("hex"),
|
|
44
|
+
...parseValidationConfig(JSON.parse(raw), path),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function parseValidationConfig(value, path) {
|
|
48
|
+
if (!isJsonRecord(value)) {
|
|
49
|
+
throw new Error(`Validation config must be a JSON object: ${path}`);
|
|
50
|
+
}
|
|
51
|
+
rejectUnknownJsonKeys(value, validationConfigKeys, "Validation config", path);
|
|
52
|
+
const requirePrecompressed = value.requirePrecompressed;
|
|
53
|
+
if (requirePrecompressed !== undefined && typeof requirePrecompressed !== "boolean") {
|
|
54
|
+
throw new Error(`Validation config requirePrecompressed must be a boolean: ${path}`);
|
|
55
|
+
}
|
|
56
|
+
const requiredMetadata = readRequiredMetadata(value.requiredMetadata, path);
|
|
57
|
+
const budgets = readValidationBudgetConfig(value.budgets, path);
|
|
58
|
+
return {
|
|
59
|
+
...(requiredMetadata === undefined ? {} : { requiredMetadata }),
|
|
60
|
+
...(budgets ? { budgets } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function readRequiredMetadata(value, path) {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
throw new Error(`Validation config requiredMetadata must be an array: ${path}`);
|
|
69
|
+
}
|
|
70
|
+
return value.map((item, index) => {
|
|
71
|
+
if (!isRequiredMetadataKey(item)) {
|
|
72
|
+
throw new Error(`Validation config requiredMetadata[${index}] must be one of ${requiredMetadataKeyList}: ${path}`);
|
|
73
|
+
}
|
|
74
|
+
return item;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function isRequiredMetadataKey(value) {
|
|
78
|
+
return typeof value === "string" && requiredMetadataKeys.includes(value);
|
|
79
|
+
}
|
|
80
|
+
function readValidationBudgetConfig(value, path) {
|
|
81
|
+
if (value === undefined) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (!isJsonRecord(value)) {
|
|
85
|
+
throw new Error(`Validation config budgets must be a JSON object: ${path}`);
|
|
86
|
+
}
|
|
87
|
+
rejectUnknownBudgetKeys(value, path);
|
|
88
|
+
const budgets = {};
|
|
89
|
+
for (const key of numericBudgetConfigKeys) {
|
|
90
|
+
const raw = value[key];
|
|
91
|
+
if (raw === undefined) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
95
|
+
throw new Error(`Validation config budgets.${key} must be a positive integer: ${path}`);
|
|
96
|
+
}
|
|
97
|
+
budgets[key] = raw;
|
|
98
|
+
}
|
|
99
|
+
if (value.maxRouteFamilyPages !== undefined) {
|
|
100
|
+
budgets.maxRouteFamilyPages = readPositiveIntegerRecord(value.maxRouteFamilyPages, "Validation config budgets.maxRouteFamilyPages", path);
|
|
101
|
+
}
|
|
102
|
+
if (value.maxArtifactFamilyOutputs !== undefined) {
|
|
103
|
+
budgets.maxArtifactFamilyOutputs = readPositiveIntegerRecord(value.maxArtifactFamilyOutputs, "Validation config budgets.maxArtifactFamilyOutputs", path);
|
|
104
|
+
}
|
|
105
|
+
return Object.keys(budgets).length > 0 ? budgets : undefined;
|
|
106
|
+
}
|
|
107
|
+
function readPositiveIntegerRecord(value, label, path) {
|
|
108
|
+
if (!isJsonRecord(value)) {
|
|
109
|
+
throw new Error(`${label} must be a JSON object: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
113
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
114
|
+
throw new Error(`${label}.${key} must be a positive integer: ${path}`);
|
|
115
|
+
}
|
|
116
|
+
result[key] = raw;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function isJsonRecord(value) {
|
|
121
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
function rejectUnknownJsonKeys(value, allowedKeys, label, path) {
|
|
124
|
+
const allowed = new Set(allowedKeys);
|
|
125
|
+
for (const key of Object.keys(value)) {
|
|
126
|
+
if (!allowed.has(key)) {
|
|
127
|
+
throw new Error(`Unknown ${label.toLowerCase()} key ${key}: ${path}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function rejectUnknownBudgetKeys(value, path) {
|
|
132
|
+
const allowed = new Set(budgetConfigKeys);
|
|
133
|
+
for (const key of Object.keys(value)) {
|
|
134
|
+
if (!allowed.has(key)) {
|
|
135
|
+
throw new Error(`Unknown validation config budgets.${key}: ${path}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const internalPageFingerprintKey = "\0stoneage:page";
|
|
140
|
+
const internalArtifactFingerprintKey = "\0stoneage:artifact";
|
|
141
|
+
export const notFoundRouteName = "\0stoneage:not-found";
|
|
142
|
+
const notFoundPath = "/404.html";
|
|
143
|
+
export const notFoundOutputPath = "404.html";
|
|
144
|
+
export function component(render) {
|
|
145
|
+
return render;
|
|
146
|
+
}
|
|
147
|
+
export function routePath(pattern, params) {
|
|
148
|
+
return pattern.replaceAll(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
|
149
|
+
const value = params[key];
|
|
150
|
+
if (value === undefined || value === null) {
|
|
151
|
+
throw new Error(`Missing route parameter: ${key}`);
|
|
152
|
+
}
|
|
153
|
+
return encodeURIComponent(String(value));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export function artifactPath(pattern, params) {
|
|
157
|
+
return normalizeArtifactPath(routePath(pattern, params));
|
|
158
|
+
}
|
|
159
|
+
export function fragmentPath(pattern, params) {
|
|
160
|
+
const filled = routePath(pattern, params);
|
|
161
|
+
const normalized = normalizeFragmentCandidatePath(filled);
|
|
162
|
+
return normalizeArtifactPath(normalized.endsWith(".html") ? normalized : `${normalized}.html`);
|
|
163
|
+
}
|
|
164
|
+
export function patternParamMatcher(pattern) {
|
|
165
|
+
return (value) => {
|
|
166
|
+
pattern.lastIndex = 0;
|
|
167
|
+
return pattern.test(value);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export function pathForRoute(pattern, params) {
|
|
171
|
+
return routePath(pattern, params);
|
|
172
|
+
}
|
|
173
|
+
export function pathForArtifact(pattern, params) {
|
|
174
|
+
return artifactPath(pattern, params);
|
|
175
|
+
}
|
|
176
|
+
export function pathForRedirect(pattern, params) {
|
|
177
|
+
return routePath(pattern, params);
|
|
178
|
+
}
|
|
179
|
+
export function redirectToRoute(redirect) {
|
|
180
|
+
return {
|
|
181
|
+
from: routePath(redirect.from, redirect.fromParams),
|
|
182
|
+
to: routePath(redirect.to, redirect.toParams),
|
|
183
|
+
...(redirect.status === undefined ? {} : { status: redirect.status }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export function redirectsFromRecords(records, options) {
|
|
187
|
+
return records.map((record, index) => redirectToRoute({
|
|
188
|
+
from: options.from,
|
|
189
|
+
fromParams: options.fromParams(record, index),
|
|
190
|
+
to: options.to,
|
|
191
|
+
toParams: options.toParams(record, index),
|
|
192
|
+
status: typeof options.status === "function"
|
|
193
|
+
? options.status(record, index)
|
|
194
|
+
: options.status,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
export function defineRouteFamily(config) {
|
|
198
|
+
return {
|
|
199
|
+
name: config.name,
|
|
200
|
+
pattern: config.pattern,
|
|
201
|
+
...(config.metadata ? { metadata: config.metadata } : {}),
|
|
202
|
+
...(config.paramMatchers ? { paramMatchers: config.paramMatchers } : {}),
|
|
203
|
+
path: (params) => routePath(config.pattern, params),
|
|
204
|
+
entries: async () => config.entries(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export function defineArtifactFamily(config) {
|
|
208
|
+
return {
|
|
209
|
+
name: config.name,
|
|
210
|
+
pattern: config.pattern,
|
|
211
|
+
...(config.paramMatchers ? { paramMatchers: config.paramMatchers } : {}),
|
|
212
|
+
entries: async () => config.entries(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export function defineFragmentFamily(config) {
|
|
216
|
+
return {
|
|
217
|
+
name: config.name,
|
|
218
|
+
pattern: config.pattern,
|
|
219
|
+
...(config.paramMatchers ? { paramMatchers: config.paramMatchers } : {}),
|
|
220
|
+
path: (params) => fragmentPath(config.pattern, params),
|
|
221
|
+
entries: async () => config.entries(),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export function defineSvgAssetFamily(config) {
|
|
225
|
+
const dir = normalizeSvgAssetDir(config.dir ?? "/_assets/svg");
|
|
226
|
+
const resolvedPaths = new Map();
|
|
227
|
+
const family = {
|
|
228
|
+
name: config.name,
|
|
229
|
+
dir,
|
|
230
|
+
...(config.paramMatchers ? { paramMatchers: config.paramMatchers } : {}),
|
|
231
|
+
path: (params) => {
|
|
232
|
+
const key = stableJson(params);
|
|
233
|
+
const path = resolvedPaths.get(key);
|
|
234
|
+
if (!path) {
|
|
235
|
+
throw new Error(`SVG asset ${config.name} has no resolved entry for params ${key}. Include the family in buildSite({ svgAssets }).`);
|
|
236
|
+
}
|
|
237
|
+
return path;
|
|
238
|
+
},
|
|
239
|
+
entries: async () => config.entries(),
|
|
240
|
+
resolvePath: (params, path) => resolvedPaths.set(stableJson(params), path),
|
|
241
|
+
clearResolvedPaths: () => resolvedPaths.clear(),
|
|
242
|
+
};
|
|
243
|
+
return family;
|
|
244
|
+
}
|
|
245
|
+
async function resolveSvgAssetFamilies(families) {
|
|
246
|
+
const tasks = [];
|
|
247
|
+
const pathsByContentHash = new Map();
|
|
248
|
+
for (const family of families) {
|
|
249
|
+
assertReservedRouteUnused("artifact", family.name);
|
|
250
|
+
const resolvedFamily = family;
|
|
251
|
+
if (typeof resolvedFamily.resolvePath !== "function" ||
|
|
252
|
+
typeof resolvedFamily.clearResolvedPaths !== "function") {
|
|
253
|
+
throw new Error(`SVG asset family ${family.name} must be created with defineSvgAssetFamily().`);
|
|
254
|
+
}
|
|
255
|
+
resolvedFamily.clearResolvedPaths();
|
|
256
|
+
const entries = await family.entries();
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
validateParamMatchers("SVG asset", family.name, entry.params, family.paramMatchers);
|
|
259
|
+
const svg = normalizeSvgAssetContent(await entry.render(), family.name);
|
|
260
|
+
const contentHash = svgAssetContentHash(svg);
|
|
261
|
+
const contentKey = `${family.dir}\0${contentHash}`;
|
|
262
|
+
const path = pathsByContentHash.get(contentKey) ?? `${family.dir}/${contentHash}.svg`;
|
|
263
|
+
pathsByContentHash.set(contentKey, path);
|
|
264
|
+
resolvedFamily.resolvePath(entry.params, path);
|
|
265
|
+
if (tasks.some((task) => task.entry.path === path)) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
tasks.push({
|
|
269
|
+
routeName: family.name,
|
|
270
|
+
pattern: path,
|
|
271
|
+
entry: {
|
|
272
|
+
params: {},
|
|
273
|
+
path,
|
|
274
|
+
dependencies: [
|
|
275
|
+
...(entry.dependencies ?? []),
|
|
276
|
+
{ key: "\0stoneage:svg-asset", hash: contentHash },
|
|
277
|
+
],
|
|
278
|
+
render: () => artifactResponse(svg, {
|
|
279
|
+
contentType: "image/svg+xml; charset=utf-8",
|
|
280
|
+
headers: {
|
|
281
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
282
|
+
"x-robots-tag": "noindex",
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return tasks;
|
|
290
|
+
}
|
|
291
|
+
export function artifactResponse(body, init = {}) {
|
|
292
|
+
return {
|
|
293
|
+
body,
|
|
294
|
+
...(init.contentType ? { contentType: init.contentType } : {}),
|
|
295
|
+
status: normalizeArtifactStatus(init.status),
|
|
296
|
+
headers: normalizeArtifactHeaders(init.headers),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export function jsonArtifact(value, options = {}) {
|
|
300
|
+
return artifactResponse(`${JSON.stringify(value, null, options.pretty ? 2 : undefined)}\n`, {
|
|
301
|
+
...options,
|
|
302
|
+
contentType: options.contentType ?? "application/json; charset=utf-8",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
export function textArtifact(body, init = {}) {
|
|
306
|
+
return artifactResponse(body, {
|
|
307
|
+
...init,
|
|
308
|
+
contentType: init.contentType ?? "text/plain; charset=utf-8",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
export function csvArtifact(body, init = {}) {
|
|
312
|
+
return artifactResponse(body, {
|
|
313
|
+
...init,
|
|
314
|
+
contentType: init.contentType ?? "text/csv; charset=utf-8",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
export function xmlArtifact(body, init = {}) {
|
|
318
|
+
return artifactResponse(body, {
|
|
319
|
+
...init,
|
|
320
|
+
contentType: init.contentType ?? "application/xml; charset=utf-8",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function htmlFragmentArtifact(body) {
|
|
324
|
+
return artifactResponse(body, {
|
|
325
|
+
contentType: "text/html; charset=utf-8",
|
|
326
|
+
headers: {
|
|
327
|
+
"x-robots-tag": "noindex",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const fragmentClientRouteName = "stoneage:fragment-client";
|
|
332
|
+
const defaultFragmentClientPublicPath = "/_stoneage/fragment-client.js";
|
|
333
|
+
const fragmentClientSource = `const selector="[data-stoneage-fragment]";
|
|
334
|
+
|
|
335
|
+
function fragmentController(){
|
|
336
|
+
const elements=Array.from(document.querySelectorAll(selector));
|
|
337
|
+
const armedElements=new WeakSet();
|
|
338
|
+
const load=async(element)=>{
|
|
339
|
+
await loadFragmentElement(element);
|
|
340
|
+
for(const nested of Array.from(element.querySelectorAll(selector))){
|
|
341
|
+
armElement(nested);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const armElement=(element)=>{
|
|
345
|
+
if(armedElements.has(element)){
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
armedElements.add(element);
|
|
349
|
+
const trigger=readFragmentTrigger(element);
|
|
350
|
+
if(trigger==="manual"){
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if(trigger==="details-open"){
|
|
354
|
+
loadWhenDetailsOpen(element,load);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if(trigger==="idle"){
|
|
358
|
+
loadWhenIdle(element,load);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
loadWhenVisible(element,load);
|
|
362
|
+
};
|
|
363
|
+
for(const element of elements){
|
|
364
|
+
armElement(element);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function loadFragmentElement(element){
|
|
369
|
+
if(element.hasAttribute("data-fragment-loaded")||element.hasAttribute("data-fragment-loading")){
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const src=element.getAttribute("data-fragment-src");
|
|
373
|
+
if(!src){
|
|
374
|
+
throw new Error("Deferred fragment placeholder is missing data-fragment-src.");
|
|
375
|
+
}
|
|
376
|
+
element.setAttribute("data-fragment-loading","true");
|
|
377
|
+
element.removeAttribute("data-fragment-error");
|
|
378
|
+
dispatchFragmentEvent(element,"stoneage:fragmentbeforeload",{src});
|
|
379
|
+
try{
|
|
380
|
+
const response=await fetch(src,{headers:{accept:"text/html"}});
|
|
381
|
+
if(!response.ok){
|
|
382
|
+
throw new Error(\`Failed to load deferred fragment \${src}: \${response.status}\`);
|
|
383
|
+
}
|
|
384
|
+
element.innerHTML=await response.text();
|
|
385
|
+
element.setAttribute("data-fragment-loaded","true");
|
|
386
|
+
element.removeAttribute("data-fragment-error");
|
|
387
|
+
dispatchFragmentEvent(element,"stoneage:fragmentload",{src});
|
|
388
|
+
}catch(error){
|
|
389
|
+
element.setAttribute("data-fragment-error","true");
|
|
390
|
+
dispatchFragmentEvent(element,"stoneage:fragmenterror",{src,error});
|
|
391
|
+
throw error;
|
|
392
|
+
}finally{
|
|
393
|
+
element.removeAttribute("data-fragment-loading");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function dispatchFragmentEvent(element,type,detail){
|
|
398
|
+
element.dispatchEvent(new CustomEvent(type,{bubbles:true,detail}));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readFragmentTrigger(element){
|
|
402
|
+
const trigger=element.getAttribute("data-fragment-trigger");
|
|
403
|
+
return trigger==="manual"||trigger==="details-open"||trigger==="idle"||trigger==="visible"
|
|
404
|
+
? trigger
|
|
405
|
+
: "visible";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function loadWhenDetailsOpen(element,load){
|
|
409
|
+
const details=element.closest("details");
|
|
410
|
+
if(!details){
|
|
411
|
+
loadWhenVisible(element,load);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if(details.open){
|
|
415
|
+
loadAutomatically(element,load);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const onToggle=()=>{
|
|
419
|
+
if(details.open){
|
|
420
|
+
loadAutomatically(element,load);
|
|
421
|
+
details.removeEventListener("toggle",onToggle);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
details.addEventListener("toggle",onToggle);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function loadWhenVisible(element,load){
|
|
428
|
+
if(typeof IntersectionObserver==="undefined"){
|
|
429
|
+
loadAutomatically(element,load);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const observer=new IntersectionObserver((entries)=>{
|
|
433
|
+
if(entries.some((entry)=>entry.isIntersecting)){
|
|
434
|
+
loadAutomatically(element,load);
|
|
435
|
+
observer.disconnect();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
observer.observe(element);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function loadWhenIdle(element,load){
|
|
442
|
+
const handle=globalThis.requestIdleCallback
|
|
443
|
+
? globalThis.requestIdleCallback(()=>loadAutomatically(element,load))
|
|
444
|
+
: setTimeout(()=>loadAutomatically(element,load),0);
|
|
445
|
+
void handle;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function loadAutomatically(element,load){
|
|
449
|
+
void load(element).catch(()=>{});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fragmentController();
|
|
453
|
+
`;
|
|
454
|
+
function fragmentFamiliesFromConfig(config) {
|
|
455
|
+
if (config === undefined) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
return Array.isArray(config) ? config : (config.families ?? []);
|
|
459
|
+
}
|
|
460
|
+
function fragmentDefaultFallbackTextFromConfig(config) {
|
|
461
|
+
return Array.isArray(config) ? undefined : config?.defaultFallbackText;
|
|
462
|
+
}
|
|
463
|
+
function fragmentClientPublicPathFromConfig(config, families) {
|
|
464
|
+
if (families.length === 0) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
if (Array.isArray(config)) {
|
|
468
|
+
return defaultFragmentClientPublicPath;
|
|
469
|
+
}
|
|
470
|
+
if (config?.client === false) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
return typeof config?.client === "object"
|
|
474
|
+
? (config.client.publicPath ?? defaultFragmentClientPublicPath)
|
|
475
|
+
: defaultFragmentClientPublicPath;
|
|
476
|
+
}
|
|
477
|
+
function fragmentClientArtifact(publicPath) {
|
|
478
|
+
return {
|
|
479
|
+
routeName: fragmentClientRouteName,
|
|
480
|
+
pattern: publicPath,
|
|
481
|
+
entry: {
|
|
482
|
+
params: {},
|
|
483
|
+
path: publicPath,
|
|
484
|
+
dependencies: [
|
|
485
|
+
{
|
|
486
|
+
key: "\0stoneage:fragment-client",
|
|
487
|
+
hash: hashText(fragmentClientSource),
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
render: () => artifactResponse(fragmentClientSource, {
|
|
491
|
+
contentType: "application/javascript; charset=utf-8",
|
|
492
|
+
headers: {
|
|
493
|
+
"x-robots-tag": "noindex",
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const immutableStaticAssetCacheControl = "public, max-age=31536000, immutable";
|
|
500
|
+
async function resolveStaticAssetReferences(assets) {
|
|
501
|
+
if (!assets) {
|
|
502
|
+
return {
|
|
503
|
+
assets,
|
|
504
|
+
publicAssets: [],
|
|
505
|
+
headers: [],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const publicAssetsByPath = new Map();
|
|
509
|
+
const headersByPath = new Map();
|
|
510
|
+
const resolveImmutablePublicAsset = async (asset, publicPath) => {
|
|
511
|
+
const bytes = await immutableAssetBytes(asset);
|
|
512
|
+
const hashedPath = contentHashedPublicPath(publicPath, contentHash(bytes));
|
|
513
|
+
if (!publicAssetsByPath.has(hashedPath)) {
|
|
514
|
+
publicAssetsByPath.set(hashedPath, "source" in asset
|
|
515
|
+
? { source: asset.source, publicPath: hashedPath }
|
|
516
|
+
: { content: bytes, publicPath: hashedPath });
|
|
517
|
+
}
|
|
518
|
+
if (!headersByPath.has(hashedPath)) {
|
|
519
|
+
headersByPath.set(hashedPath, {
|
|
520
|
+
path: hashedPath,
|
|
521
|
+
headers: {
|
|
522
|
+
"cache-control": immutableStaticAssetCacheControl,
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return hashedPath;
|
|
527
|
+
};
|
|
528
|
+
const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset)));
|
|
529
|
+
const client = assets.client
|
|
530
|
+
? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset)
|
|
531
|
+
: undefined;
|
|
532
|
+
return {
|
|
533
|
+
assets: {
|
|
534
|
+
...assets,
|
|
535
|
+
...(assets.stylesheets ? { stylesheets } : {}),
|
|
536
|
+
...(client ? { client } : {}),
|
|
537
|
+
},
|
|
538
|
+
publicAssets: [...publicAssetsByPath.values()],
|
|
539
|
+
headers: [...headersByPath.values()],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset) {
|
|
543
|
+
if (typeof stylesheet === "string" || stylesheet.immutable === false) {
|
|
544
|
+
return stylesheet;
|
|
545
|
+
}
|
|
546
|
+
if ("source" in stylesheet) {
|
|
547
|
+
return resolveImmutablePublicAsset({ source: stylesheet.source }, stylesheet.href);
|
|
548
|
+
}
|
|
549
|
+
return resolveGoogleFontsStylesheet(stylesheet.href, stylesheet.googleFonts, resolveImmutablePublicAsset);
|
|
550
|
+
}
|
|
551
|
+
async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset) {
|
|
552
|
+
const entries = await Promise.all(Object.entries(registry).map(async ([id, declarations]) => [
|
|
553
|
+
id,
|
|
554
|
+
await Promise.all((Array.isArray(declarations) ? declarations : [declarations]).map(async (declaration) => ({
|
|
555
|
+
...(declaration.stylesheets
|
|
556
|
+
? {
|
|
557
|
+
stylesheets: await Promise.all(declaration.stylesheets.map((stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset))),
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
560
|
+
...(declaration.scripts
|
|
561
|
+
? {
|
|
562
|
+
scripts: await Promise.all(declaration.scripts.map((script) => resolveClientScriptAsset(script, resolveImmutablePublicAsset))),
|
|
563
|
+
}
|
|
564
|
+
: {}),
|
|
565
|
+
}))),
|
|
566
|
+
]));
|
|
567
|
+
return Object.fromEntries(entries);
|
|
568
|
+
}
|
|
569
|
+
async function resolveClientScriptAsset(script, resolveImmutablePublicAsset) {
|
|
570
|
+
if (!script.source || script.immutable === false) {
|
|
571
|
+
const { source: _source, immutable: _immutable, ...runtimeScript } = script;
|
|
572
|
+
return runtimeScript;
|
|
573
|
+
}
|
|
574
|
+
const src = await resolveImmutablePublicAsset({ source: script.source }, script.src);
|
|
575
|
+
const { source: _source, immutable: _immutable, ...runtimeScript } = script;
|
|
576
|
+
return {
|
|
577
|
+
...runtimeScript,
|
|
578
|
+
src,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
async function immutableAssetBytes(asset) {
|
|
582
|
+
return "source" in asset ? readFile(asset.source) : Buffer.from(asset.content);
|
|
583
|
+
}
|
|
584
|
+
const defaultGoogleFontsCssEndpoint = "https://fonts.googleapis.com/css2";
|
|
585
|
+
const defaultGoogleFontsUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
586
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
587
|
+
async function resolveGoogleFontsStylesheet(href, options, resolveImmutablePublicAsset) {
|
|
588
|
+
const fetchAsset = options.fetch ?? defaultAssetFetch;
|
|
589
|
+
const requestHeaders = {
|
|
590
|
+
"user-agent": options.userAgent ?? defaultGoogleFontsUserAgent,
|
|
591
|
+
accept: "text/css,*/*;q=0.1",
|
|
592
|
+
};
|
|
593
|
+
const cssUrl = googleFontsCssUrl(options);
|
|
594
|
+
const cssResponse = await fetchAsset(cssUrl, { headers: requestHeaders });
|
|
595
|
+
if (!cssResponse.ok) {
|
|
596
|
+
throw new Error(`Google Fonts CSS request failed with status ${cssResponse.status}: ${cssUrl}`);
|
|
597
|
+
}
|
|
598
|
+
const css = await cssResponse.text();
|
|
599
|
+
const fontPathsByUrl = new Map();
|
|
600
|
+
for (const font of googleFontsCssFonts(css)) {
|
|
601
|
+
if (fontPathsByUrl.has(font.url)) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const fontPublicPath = googleFontPublicPath(font.url, font.format, options.fontBasePath ?? defaultGoogleFontBasePath(href));
|
|
605
|
+
const fontResponse = await fetchAsset(font.url, { headers: requestHeaders });
|
|
606
|
+
if (!fontResponse.ok) {
|
|
607
|
+
throw new Error(`Google Fonts file request failed with status ${fontResponse.status}: ${font.url}`);
|
|
608
|
+
}
|
|
609
|
+
const bytes = Buffer.from(await fontResponse.arrayBuffer());
|
|
610
|
+
fontPathsByUrl.set(font.url, await resolveImmutablePublicAsset({ content: bytes }, fontPublicPath));
|
|
611
|
+
}
|
|
612
|
+
const rewrittenCss = rewriteCssUrls(css, fontPathsByUrl);
|
|
613
|
+
return resolveImmutablePublicAsset({ content: rewrittenCss }, href);
|
|
614
|
+
}
|
|
615
|
+
const defaultAssetFetch = async (url, init) => {
|
|
616
|
+
return fetch(url, init);
|
|
617
|
+
};
|
|
618
|
+
function googleFontsCssUrl(options) {
|
|
619
|
+
if (options.families.length === 0) {
|
|
620
|
+
throw new Error("Google Fonts stylesheet requires at least one family.");
|
|
621
|
+
}
|
|
622
|
+
const url = new URL(options.endpoint ?? defaultGoogleFontsCssEndpoint);
|
|
623
|
+
for (const family of options.families) {
|
|
624
|
+
url.searchParams.append("family", family);
|
|
625
|
+
}
|
|
626
|
+
url.searchParams.set("display", options.display ?? "swap");
|
|
627
|
+
if (options.text !== undefined) {
|
|
628
|
+
url.searchParams.set("text", options.text);
|
|
629
|
+
}
|
|
630
|
+
return url.toString();
|
|
631
|
+
}
|
|
632
|
+
function googleFontsCssFonts(css) {
|
|
633
|
+
const fonts = [];
|
|
634
|
+
const seen = new Set();
|
|
635
|
+
const pattern = /url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")\s]+))\s*\)\s*(?:format\(\s*(?:"([^"]+)"|'([^']+)'|([^'")\s]+))\s*\))?/g;
|
|
636
|
+
let match;
|
|
637
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
638
|
+
const url = match[1] ?? match[2] ?? match[3];
|
|
639
|
+
if (!url || seen.has(url)) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
seen.add(url);
|
|
643
|
+
fonts.push({
|
|
644
|
+
url,
|
|
645
|
+
format: match[4] ?? match[5] ?? match[6],
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return fonts;
|
|
649
|
+
}
|
|
650
|
+
function googleFontPublicPath(fontUrl, format, fontBasePath) {
|
|
651
|
+
const url = new URL(fontUrl);
|
|
652
|
+
const normalizedFormat = format?.replace(/^['"]|['"]$/g, "").toLowerCase();
|
|
653
|
+
if (normalizedFormat !== "woff2") {
|
|
654
|
+
throw new Error(`Google Fonts stylesheet contains a font URL without format(woff2): ${fontUrl}`);
|
|
655
|
+
}
|
|
656
|
+
const basename = posix.basename(url.pathname);
|
|
657
|
+
const filename = basename.endsWith(".woff2") ? basename : "font.woff2";
|
|
658
|
+
const base = fontBasePath.endsWith("/") ? fontBasePath : `${fontBasePath}/`;
|
|
659
|
+
return `${base}${filename}`;
|
|
660
|
+
}
|
|
661
|
+
function defaultGoogleFontBasePath(stylesheetHref) {
|
|
662
|
+
const outputPath = publicOutputPathForAsset(stylesheetHref);
|
|
663
|
+
const directory = posix.dirname(outputPath);
|
|
664
|
+
const extension = posix.extname(outputPath);
|
|
665
|
+
const basename = extension
|
|
666
|
+
? posix.basename(outputPath, extension)
|
|
667
|
+
: posix.basename(outputPath);
|
|
668
|
+
return `/${directory === "." ? basename : posix.join(directory, basename)}/`;
|
|
669
|
+
}
|
|
670
|
+
function rewriteCssUrls(css, replacements) {
|
|
671
|
+
let output = css;
|
|
672
|
+
for (const [from, to] of replacements) {
|
|
673
|
+
output = output.split(from).join(to);
|
|
674
|
+
}
|
|
675
|
+
return output;
|
|
676
|
+
}
|
|
677
|
+
function contentHash(bytes) {
|
|
678
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, 16);
|
|
679
|
+
}
|
|
680
|
+
function contentHashedPublicPath(publicPath, hash) {
|
|
681
|
+
if (/[?#]/.test(publicPath)) {
|
|
682
|
+
throw new Error(`Immutable public asset path must not include a query or fragment: ${publicPath}`);
|
|
683
|
+
}
|
|
684
|
+
const outputPath = publicOutputPathForAsset(publicPath);
|
|
685
|
+
const lastSlash = outputPath.lastIndexOf("/");
|
|
686
|
+
const directory = lastSlash === -1 ? "" : outputPath.slice(0, lastSlash + 1);
|
|
687
|
+
const filename = lastSlash === -1 ? outputPath : outputPath.slice(lastSlash + 1);
|
|
688
|
+
const extensionIndex = filename.lastIndexOf(".");
|
|
689
|
+
const hashedFilename = extensionIndex <= 0
|
|
690
|
+
? `${filename}.${hash}`
|
|
691
|
+
: `${filename.slice(0, extensionIndex)}.${hash}${filename.slice(extensionIndex)}`;
|
|
692
|
+
return `/${directory}${hashedFilename}`;
|
|
693
|
+
}
|
|
694
|
+
function canSkipUnchangedFingerprintBuild(config, previousManifest, previousReport, currentPublicAssets, sitemapFiles, publicAssetsCopied, fragmentDefaultFallbackText, fragmentClientPublicPath) {
|
|
695
|
+
if (config.buildFingerprint === undefined ||
|
|
696
|
+
config.partial ||
|
|
697
|
+
config.validation !== undefined ||
|
|
698
|
+
previousReport.buildFingerprint !== config.buildFingerprint ||
|
|
699
|
+
previousReport.renderFingerprint !== config.renderFingerprint ||
|
|
700
|
+
previousReport.fragmentDefaultFallbackText !== fragmentDefaultFallbackText ||
|
|
701
|
+
previousReport.fragmentClientPublicPath !== fragmentClientPublicPath ||
|
|
702
|
+
publicAssetsCopied > 0) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
const hasPreviousOutputs = previousManifest.pages.length > 0 || previousManifest.artifacts.length > 0;
|
|
706
|
+
return (hasPreviousOutputs &&
|
|
707
|
+
sameStringList(previousReport.publicAssets, currentPublicAssets) &&
|
|
708
|
+
sameStringList(previousReport.sitemapFiles, sitemapFiles));
|
|
709
|
+
}
|
|
710
|
+
export async function buildSite(config) {
|
|
711
|
+
const trailingSlash = config.trailingSlash ?? "always";
|
|
712
|
+
const fragmentFamilies = fragmentFamiliesFromConfig(config.fragments);
|
|
713
|
+
const fragmentDefaultFallbackText = fragmentDefaultFallbackTextFromConfig(config.fragments);
|
|
714
|
+
const fragmentClientPublicPath = fragmentClientPublicPathFromConfig(config.fragments, fragmentFamilies);
|
|
715
|
+
const fragmentClientScripts = fragmentClientPublicPath
|
|
716
|
+
? [{ src: fragmentClientPublicPath, module: true }]
|
|
717
|
+
: [];
|
|
718
|
+
const staticAssets = await resolveStaticAssetReferences(config.assets);
|
|
719
|
+
const assets = staticAssets.assets;
|
|
720
|
+
const resolvedPublicAssets = [
|
|
721
|
+
...(config.assets?.publicDir ? await expandPublicDir(config.assets.publicDir) : []),
|
|
722
|
+
...(config.assets?.public ?? []),
|
|
723
|
+
...staticAssets.publicAssets,
|
|
724
|
+
];
|
|
725
|
+
const publicAssetSync = resolvedPublicAssets.length
|
|
726
|
+
? await copyPublicAssets({
|
|
727
|
+
outDir: config.outDir,
|
|
728
|
+
assets: resolvedPublicAssets,
|
|
729
|
+
})
|
|
730
|
+
: { copied: [], skipped: [] };
|
|
731
|
+
const publicAssetsCopied = publicAssetSync.copied.length;
|
|
732
|
+
const publicAssetsSkipped = publicAssetSync.skipped.length;
|
|
733
|
+
const currentPublicAssets = managedPublicAssetPaths(resolvedPublicAssets);
|
|
734
|
+
const previousManifest = await readManifest(config.outDir);
|
|
735
|
+
const previousReport = await readBuildReport(config.outDir);
|
|
736
|
+
const svgAssetTasks = await resolveSvgAssetFamilies(config.svgAssets ?? []);
|
|
737
|
+
const compareExistingGeneratedOutput = previousManifest.pages.length > 0 ||
|
|
738
|
+
previousManifest.artifacts.length > 0 ||
|
|
739
|
+
previousReport !== undefined;
|
|
740
|
+
const publicAssets = config.partial
|
|
741
|
+
? mergePublicAssets(previousReport?.publicAssets ?? [], currentPublicAssets)
|
|
742
|
+
: currentPublicAssets;
|
|
743
|
+
const unchangedBuildSitemapFiles = sitemapFilesFor(previousManifest, config.sitemap);
|
|
744
|
+
if (svgAssetTasks.length === 0 &&
|
|
745
|
+
previousReport !== undefined &&
|
|
746
|
+
canSkipUnchangedFingerprintBuild(config, previousManifest, previousReport, currentPublicAssets, unchangedBuildSitemapFiles, publicAssetsCopied, fragmentDefaultFallbackText, fragmentClientPublicPath)) {
|
|
747
|
+
const report = unchangedBuildReportFromPrevious(previousReport, {
|
|
748
|
+
pagesWritten: 0,
|
|
749
|
+
artifactsWritten: 0,
|
|
750
|
+
publicAssetsCopied,
|
|
751
|
+
publicAssetsSkipped,
|
|
752
|
+
publicAssets,
|
|
753
|
+
staleOutputsRemoved: 0,
|
|
754
|
+
sitemapRendered: false,
|
|
755
|
+
sitemapFiles: unchangedBuildSitemapFiles,
|
|
756
|
+
routeWrites: {},
|
|
757
|
+
artifactWrites: {},
|
|
758
|
+
});
|
|
759
|
+
return {
|
|
760
|
+
pagesWritten: 0,
|
|
761
|
+
artifactsWritten: 0,
|
|
762
|
+
publicAssetsCopied,
|
|
763
|
+
publicAssetsSkipped,
|
|
764
|
+
manifest: previousManifest,
|
|
765
|
+
report,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
const previousPages = new Map(previousManifest.pages.map((page) => [page.path, page]));
|
|
769
|
+
const previousArtifacts = new Map(previousManifest.artifacts.map((artifact) => [artifact.path, artifact]));
|
|
770
|
+
const pageTasks = [];
|
|
771
|
+
const artifactTasks = [
|
|
772
|
+
...svgAssetTasks,
|
|
773
|
+
...(fragmentClientPublicPath ? [fragmentClientArtifact(fragmentClientPublicPath)] : []),
|
|
774
|
+
];
|
|
775
|
+
for (const route of config.routes) {
|
|
776
|
+
const entries = await route.entries();
|
|
777
|
+
for (const entry of entries) {
|
|
778
|
+
validateParamMatchers("route", route.name, entry.params, route.paramMatchers);
|
|
779
|
+
pageTasks.push({
|
|
780
|
+
routeName: route.name,
|
|
781
|
+
pattern: route.pattern,
|
|
782
|
+
metadataDefaults: route.metadata,
|
|
783
|
+
entry,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const artifact of config.artifacts ?? []) {
|
|
788
|
+
assertReservedRouteUnused("artifact", artifact.name);
|
|
789
|
+
const entries = await artifact.entries();
|
|
790
|
+
for (const entry of entries) {
|
|
791
|
+
validateParamMatchers("artifact", artifact.name, entry.params, artifact.paramMatchers);
|
|
792
|
+
artifactTasks.push({
|
|
793
|
+
routeName: artifact.name,
|
|
794
|
+
pattern: artifact.pattern,
|
|
795
|
+
entry,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
for (const fragment of fragmentFamilies) {
|
|
800
|
+
assertReservedRouteUnused("fragment", fragment.name);
|
|
801
|
+
const entries = await fragment.entries();
|
|
802
|
+
for (const entry of entries) {
|
|
803
|
+
validateParamMatchers("fragment", fragment.name, entry.params, fragment.paramMatchers);
|
|
804
|
+
artifactTasks.push({
|
|
805
|
+
routeName: fragment.name,
|
|
806
|
+
pattern: fragment.pattern,
|
|
807
|
+
entry: {
|
|
808
|
+
params: entry.params,
|
|
809
|
+
path: entry.path ? fragmentPath(entry.path, {}) : fragment.path(entry.params),
|
|
810
|
+
...(entry.dependencies ? { dependencies: entry.dependencies } : {}),
|
|
811
|
+
render: async () => htmlFragmentArtifact(await entry.render()),
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const plannedPages = pageTasks.map((task) => {
|
|
817
|
+
const path = normalizePath(task.entry.path ?? routePath(task.pattern, task.entry.params), trailingSlash);
|
|
818
|
+
const pathDefaults = config.site.metadata?.(path);
|
|
819
|
+
const metadata = resolvePageMetadata(task.metadataDefaults, pathDefaults, task.entry.metadata);
|
|
820
|
+
return {
|
|
821
|
+
...task,
|
|
822
|
+
path,
|
|
823
|
+
metadata,
|
|
824
|
+
declaredLinks: task.entry.links === undefined
|
|
825
|
+
? undefined
|
|
826
|
+
: normalizeDeclaredInternalLinks(task.entry.links, trailingSlash),
|
|
827
|
+
outputPath: outputPathFor(path, trailingSlash),
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
const plannedArtifacts = artifactTasks.map((task) => {
|
|
831
|
+
const path = normalizeArtifactPath(task.entry.path ?? artifactPath(task.pattern, task.entry.params));
|
|
832
|
+
return {
|
|
833
|
+
...task,
|
|
834
|
+
path,
|
|
835
|
+
outputPath: outputPathForArtifact(path),
|
|
836
|
+
};
|
|
837
|
+
});
|
|
838
|
+
validateOutputPlan(plannedPages, plannedArtifacts, config.notFound !== undefined);
|
|
839
|
+
const sharedPageFingerprintHash = hashValue({
|
|
840
|
+
site: config.site,
|
|
841
|
+
stylesheets: assets?.stylesheets ?? [],
|
|
842
|
+
prefetch: config.prefetch ?? {},
|
|
843
|
+
trailingSlash,
|
|
844
|
+
renderFingerprint: config.renderFingerprint,
|
|
845
|
+
fragmentDefaultFallbackText,
|
|
846
|
+
fragmentClientPublicPath,
|
|
847
|
+
});
|
|
848
|
+
const pageResults = await mapConcurrent(plannedPages, config.concurrency ?? 64, async (task) => {
|
|
849
|
+
const path = task.path;
|
|
850
|
+
const outputPath = task.outputPath;
|
|
851
|
+
const absoluteOutputPath = join(config.outDir, outputPath);
|
|
852
|
+
const canonical = task.metadata.canonical ?? absoluteUrl(config.site.baseUrl, path, trailingSlash);
|
|
853
|
+
const dependencies = await hashDependencies([
|
|
854
|
+
...(task.entry.dependencies ?? []),
|
|
855
|
+
{
|
|
856
|
+
key: internalPageFingerprintKey,
|
|
857
|
+
hash: pageFingerprintHash(task.routeName, path, outputPath, canonical, task.metadata, sharedPageFingerprintHash, task.declaredLinks),
|
|
858
|
+
},
|
|
859
|
+
]);
|
|
860
|
+
const previousPage = previousPages.get(path);
|
|
861
|
+
if (previousPage && sameDependencies(previousPage.dependencies, dependencies)) {
|
|
862
|
+
return {
|
|
863
|
+
page: previousPage,
|
|
864
|
+
validationPage: validationPageFor(path, canonical, task.metadata, config.site),
|
|
865
|
+
pagesWritten: 0,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => task.entry.render());
|
|
869
|
+
const html = renderDocument(config.site, task.metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
|
|
870
|
+
await writeText(absoluteOutputPath, html, {
|
|
871
|
+
compareExisting: compareExistingGeneratedOutput,
|
|
872
|
+
});
|
|
873
|
+
return {
|
|
874
|
+
page: {
|
|
875
|
+
route: task.routeName,
|
|
876
|
+
path,
|
|
877
|
+
outputPath,
|
|
878
|
+
dependencies,
|
|
879
|
+
links: task.declaredLinks ?? extractInternalLinks(html, trailingSlash),
|
|
880
|
+
htmlBytes: Buffer.byteLength(html),
|
|
881
|
+
sitemap: task.metadata.sitemap !== false && task.metadata.noindex !== true,
|
|
882
|
+
},
|
|
883
|
+
validationPage: validationPageFor(path, canonical, task.metadata, config.site),
|
|
884
|
+
pagesWritten: 1,
|
|
885
|
+
};
|
|
886
|
+
});
|
|
887
|
+
const publishArtifactHeaders = config.publishing !== undefined && config.publishing.headers !== false;
|
|
888
|
+
const artifactResults = await mapConcurrent(plannedArtifacts, config.concurrency ?? 64, async (task) => {
|
|
889
|
+
const path = task.path;
|
|
890
|
+
const outputPath = task.outputPath;
|
|
891
|
+
const absoluteOutputPath = join(config.outDir, outputPath);
|
|
892
|
+
const dependencies = await hashDependencies([
|
|
893
|
+
...(task.entry.dependencies ?? []),
|
|
894
|
+
{
|
|
895
|
+
key: internalArtifactFingerprintKey,
|
|
896
|
+
hash: artifactFingerprintHash(task.routeName, path, outputPath, config.renderFingerprint, fragmentDefaultFallbackText),
|
|
897
|
+
},
|
|
898
|
+
]);
|
|
899
|
+
const previousArtifact = previousArtifacts.get(path);
|
|
900
|
+
if (previousArtifact && sameDependencies(previousArtifact.dependencies, dependencies)) {
|
|
901
|
+
if (publishArtifactHeaders) {
|
|
902
|
+
const content = artifactContent(await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => task.entry.render()));
|
|
903
|
+
return {
|
|
904
|
+
artifact: {
|
|
905
|
+
route: task.routeName,
|
|
906
|
+
path,
|
|
907
|
+
outputPath,
|
|
908
|
+
dependencies,
|
|
909
|
+
bytes: previousArtifact.bytes,
|
|
910
|
+
...content.metadata,
|
|
911
|
+
},
|
|
912
|
+
artifactsWritten: 0,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
artifact: previousArtifact,
|
|
917
|
+
artifactsWritten: 0,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const content = artifactContent(await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => task.entry.render()));
|
|
921
|
+
const bytes = toBuffer(content.body);
|
|
922
|
+
await writeBytes(absoluteOutputPath, bytes, {
|
|
923
|
+
compareExisting: compareExistingGeneratedOutput,
|
|
924
|
+
});
|
|
925
|
+
return {
|
|
926
|
+
artifact: {
|
|
927
|
+
route: task.routeName,
|
|
928
|
+
path,
|
|
929
|
+
outputPath,
|
|
930
|
+
dependencies,
|
|
931
|
+
bytes: bytes.byteLength,
|
|
932
|
+
...content.metadata,
|
|
933
|
+
},
|
|
934
|
+
artifactsWritten: 1,
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
const notFoundResults = config.notFound
|
|
938
|
+
? [
|
|
939
|
+
await buildNotFoundPage(config, config.notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets),
|
|
940
|
+
]
|
|
941
|
+
: [];
|
|
942
|
+
const allPageResults = [...pageResults, ...notFoundResults];
|
|
943
|
+
const pages = config.partial
|
|
944
|
+
? mergePartialPages(previousManifest.pages, allPageResults.map((result) => result.page))
|
|
945
|
+
: allPageResults.map((result) => result.page);
|
|
946
|
+
const builtArtifacts = artifactResults.map((result) => result.artifact);
|
|
947
|
+
// Plan redirect HTML stubs before assembling the artifact list so they can be
|
|
948
|
+
// tracked in the manifest (orphan/stale/partial all key off manifest entries).
|
|
949
|
+
//
|
|
950
|
+
// `occupied` must reflect the *final merged* outputs, not just this build's
|
|
951
|
+
// results: on a partial rebuild only a subset of routes/artifacts is
|
|
952
|
+
// re-enumerated, so collision detection against `pageResults`/`builtArtifacts`
|
|
953
|
+
// alone would miss pages/artifacts that `mergePartialPages` /
|
|
954
|
+
// `mergePartialArtifacts` carry over from the previous manifest. Missing them
|
|
955
|
+
// would let a redirect stub overwrite a real (retained) page on disk and add a
|
|
956
|
+
// duplicate `outputPath` to the manifest.
|
|
957
|
+
//
|
|
958
|
+
// Output paths are recomputed from each entry's `path` with the *current*
|
|
959
|
+
// build's `trailingSlash` rather than read from `page.outputPath`: the
|
|
960
|
+
// persisted manifest does not store `trailingSlash`, so pages reconstructed
|
|
961
|
+
// from it default to "always"-style output paths (e.g. `foo/index.html`) which
|
|
962
|
+
// would not match a redirect stub planned under `trailingSlash: "never"`
|
|
963
|
+
// (`foo.html`). The redirect stub path itself is derived via
|
|
964
|
+
// `outputPathFor(from, trailingSlash)`, so deriving occupied paths the same way
|
|
965
|
+
// keeps collision detection correct across trailing-slash modes.
|
|
966
|
+
//
|
|
967
|
+
// `publicAssets` is already partial-merged above.
|
|
968
|
+
//
|
|
969
|
+
// On a partial rebuild `previousManifest.artifacts` includes the redirect stubs
|
|
970
|
+
// registered by earlier builds. A redirect's own prior stub must NOT count as
|
|
971
|
+
// occupied, otherwise re-planning the same redirect would see that stub and
|
|
972
|
+
// skip it, dropping the redirect from the manifest.
|
|
973
|
+
//
|
|
974
|
+
// A prior stub is recognised by BOTH signals: its reserved `route`
|
|
975
|
+
// (REDIRECT_ARTIFACT_ROUTE, rejected as a user family name going forward) AND
|
|
976
|
+
// its output path being one the current config would plan a redirect for. The
|
|
977
|
+
// route alone is not sufficient because a manifest written by an older version
|
|
978
|
+
// (before the name was reserved) could legitimately contain a user artifact
|
|
979
|
+
// family named "redirect"; requiring the path to match a current redirect
|
|
980
|
+
// candidate keeps such a retained real artifact in the occupied set so a newly
|
|
981
|
+
// configured redirect cannot overwrite it.
|
|
982
|
+
//
|
|
983
|
+
// Output paths owned by a *real* (non-redirect) output in this build: the
|
|
984
|
+
// merged pages, this build's artifacts, and the (already merged) public assets.
|
|
985
|
+
const realOwnerOutputPaths = new Set([
|
|
986
|
+
...pages.map((page) => outputPathFor(page.path, trailingSlash)),
|
|
987
|
+
...builtArtifacts.map((artifact) => artifact.outputPath),
|
|
988
|
+
...publicAssets.map(publicAssetOutputPath),
|
|
989
|
+
]);
|
|
990
|
+
const redirectCandidateOutputPaths = redirectOutputPathSet(config.publishing?.redirectHtml ? config.publishing.redirects : undefined, trailingSlash);
|
|
991
|
+
const occupiedOutputPaths = new Set([
|
|
992
|
+
...realOwnerOutputPaths,
|
|
993
|
+
...(config.partial
|
|
994
|
+
? previousManifest.artifacts.flatMap((artifact) => {
|
|
995
|
+
const outputPath = outputPathForArtifact(artifact.path);
|
|
996
|
+
const isPriorRedirectStub = artifact.route === REDIRECT_ARTIFACT_ROUTE &&
|
|
997
|
+
redirectCandidateOutputPaths.has(outputPath);
|
|
998
|
+
return isPriorRedirectStub ? [] : [outputPath];
|
|
999
|
+
})
|
|
1000
|
+
: []),
|
|
1001
|
+
]);
|
|
1002
|
+
const redirectHtmlFiles = config.publishing?.redirectHtml && config.publishing.redirects?.length
|
|
1003
|
+
? planRedirectHtmlFiles(config.publishing.redirects, config.site.baseUrl, trailingSlash, occupiedOutputPaths)
|
|
1004
|
+
: [];
|
|
1005
|
+
const redirectArtifacts = redirectHtmlFiles.map(redirectArtifact);
|
|
1006
|
+
const changedArtifacts = [...builtArtifacts, ...redirectArtifacts];
|
|
1007
|
+
// Redirect stubs are keyed by their (unique) output path, so an unchanged
|
|
1008
|
+
// redirect config re-plans the same stub every build and `mergePartialArtifacts`
|
|
1009
|
+
// replaces the previous entry in place (no duplication). Stale redirect stubs
|
|
1010
|
+
// whose source was removed are handled by removeStaleGeneratedOutputs on a full
|
|
1011
|
+
// rebuild exactly like any other generated output; partial rebuilds defer disk
|
|
1012
|
+
// cleanup for all output kinds.
|
|
1013
|
+
//
|
|
1014
|
+
// The one case the plain merge would mishandle is a path that hosted a retained
|
|
1015
|
+
// artifact in the previous manifest but is now owned by a real page/artifact in
|
|
1016
|
+
// this build: keeping the retained entry would leave a duplicate `outputPath`.
|
|
1017
|
+
// Drop retained artifacts whose output path is now a real owner so the manifest
|
|
1018
|
+
// never lists two outputs for the same path.
|
|
1019
|
+
const mergeBaseArtifacts = config.partial
|
|
1020
|
+
? previousManifest.artifacts.filter((artifact) => !realOwnerOutputPaths.has(outputPathForArtifact(artifact.path)))
|
|
1021
|
+
: previousManifest.artifacts;
|
|
1022
|
+
const artifacts = config.partial
|
|
1023
|
+
? mergePartialArtifacts(mergeBaseArtifacts, changedArtifacts)
|
|
1024
|
+
: changedArtifacts;
|
|
1025
|
+
// The synthetic not-found page is included in `pages`/`pagesWritten` (so it is
|
|
1026
|
+
// registered in the manifest) but excluded from route-family reporting below,
|
|
1027
|
+
// which iterates `pageResults`, so it never appears as a phantom route.
|
|
1028
|
+
const pagesWritten = allPageResults.reduce((total, result) => total + result.pagesWritten, 0);
|
|
1029
|
+
// Redirect HTML stubs are (re)written on every build (writePublishingManifests
|
|
1030
|
+
// writes each planned file unconditionally), so each planned stub counts as one
|
|
1031
|
+
// artifact write. Folding them into artifactsWritten/artifactWrites keeps the
|
|
1032
|
+
// build report and data-flow family summaries honest instead of reporting the
|
|
1033
|
+
// tracked "redirect" family as fully skipped.
|
|
1034
|
+
const artifactsWritten = artifactResults.reduce((total, result) => total + result.artifactsWritten, 0) +
|
|
1035
|
+
redirectArtifacts.length;
|
|
1036
|
+
const routeWrites = countWrites(pageResults, (result) => result.page.route, (result) => result.pagesWritten);
|
|
1037
|
+
const artifactWrites = countWrites(artifactResults, (result) => result.artifact.route, (result) => result.artifactsWritten);
|
|
1038
|
+
if (redirectArtifacts.length > 0) {
|
|
1039
|
+
artifactWrites.redirect = (artifactWrites.redirect ?? 0) + redirectArtifacts.length;
|
|
1040
|
+
}
|
|
1041
|
+
const manifest = {
|
|
1042
|
+
version: 1,
|
|
1043
|
+
pages,
|
|
1044
|
+
artifacts,
|
|
1045
|
+
};
|
|
1046
|
+
const staleOutputsRemoved = config.partial
|
|
1047
|
+
? 0
|
|
1048
|
+
: (await removeStaleGeneratedOutputs(config.outDir, previousManifest, manifest)) +
|
|
1049
|
+
(await removeStalePublicAssetOutputs(config.outDir, previousReport?.publicAssets ?? [], currentPublicAssets));
|
|
1050
|
+
const sitemapFiles = sitemapFilesFor(manifest, config.sitemap);
|
|
1051
|
+
const sitemapRendered = previousManifest.pages.length === 0 || !sameSitemapEntries(previousManifest.pages, pages);
|
|
1052
|
+
const manifestEntriesReused = reusesManifestEntries(previousManifest, manifest);
|
|
1053
|
+
const canReusePreviousReport = previousReport !== undefined &&
|
|
1054
|
+
config.validation === undefined &&
|
|
1055
|
+
previousReport.validation.configPath === undefined &&
|
|
1056
|
+
previousReport.validation.configHash === undefined &&
|
|
1057
|
+
manifestEntriesReused &&
|
|
1058
|
+
pagesWritten === 0 &&
|
|
1059
|
+
artifactsWritten === 0 &&
|
|
1060
|
+
staleOutputsRemoved === 0;
|
|
1061
|
+
const report = canReusePreviousReport
|
|
1062
|
+
? unchangedBuildReportFromPrevious(previousReport, {
|
|
1063
|
+
pagesWritten,
|
|
1064
|
+
artifactsWritten,
|
|
1065
|
+
publicAssetsCopied,
|
|
1066
|
+
publicAssetsSkipped,
|
|
1067
|
+
publicAssets,
|
|
1068
|
+
staleOutputsRemoved,
|
|
1069
|
+
sitemapRendered,
|
|
1070
|
+
sitemapFiles,
|
|
1071
|
+
routeWrites,
|
|
1072
|
+
artifactWrites,
|
|
1073
|
+
})
|
|
1074
|
+
: createReport(manifest, pagesWritten, artifactsWritten, publicAssetsCopied, publicAssetsSkipped, publicAssets, staleOutputsRemoved, sitemapRendered, sitemapFiles, routeWrites, artifactWrites, allPageResults.map((result) => result.validationPage), config.validation, config.buildFingerprint, config.renderFingerprint, fragmentDefaultFallbackText, fragmentClientPublicPath);
|
|
1075
|
+
const missingPreviousManifest = previousManifest.pages.length === 0 && previousManifest.artifacts.length === 0;
|
|
1076
|
+
const missingBuildReport = previousReport === undefined;
|
|
1077
|
+
const dataFlowSummaryReportPath = join(config.outDir, ".stoneage/data-flow-summary.json");
|
|
1078
|
+
const dataFlowDependenciesReportPath = join(config.outDir, ".stoneage/data-flow-dependencies.json");
|
|
1079
|
+
const legacyDataFlowReportPath = join(config.outDir, ".stoneage/data-flow.json");
|
|
1080
|
+
const missingDataFlowReport = (await isMissingFile(dataFlowSummaryReportPath)) ||
|
|
1081
|
+
(await isMissingFile(dataFlowDependenciesReportPath));
|
|
1082
|
+
const shouldWriteSitemaps = sitemapRendered || config.sitemap?.maxUrlsPerFile !== undefined;
|
|
1083
|
+
const shouldWriteBuildReportsWithoutComparisons = pagesWritten > 0 ||
|
|
1084
|
+
artifactsWritten > 0 ||
|
|
1085
|
+
publicAssetsCopied > 0 ||
|
|
1086
|
+
publicAssetsSkipped > 0 ||
|
|
1087
|
+
staleOutputsRemoved > 0 ||
|
|
1088
|
+
missingPreviousManifest ||
|
|
1089
|
+
missingBuildReport ||
|
|
1090
|
+
shouldWriteSitemaps;
|
|
1091
|
+
const validationReportChanged = !shouldWriteBuildReportsWithoutComparisons &&
|
|
1092
|
+
!canReusePreviousReport &&
|
|
1093
|
+
previousReport !== undefined &&
|
|
1094
|
+
stableJson(previousReport.validation) !== stableJson(report.validation);
|
|
1095
|
+
const manifestChanged = !shouldWriteBuildReportsWithoutComparisons &&
|
|
1096
|
+
!missingPreviousManifest &&
|
|
1097
|
+
!manifestEntriesReused &&
|
|
1098
|
+
stableJson(previousManifest) !== stableJson(manifest);
|
|
1099
|
+
const shouldWriteBuildReports = shouldWriteBuildReportsWithoutComparisons || manifestChanged || validationReportChanged;
|
|
1100
|
+
if (shouldWriteBuildReports) {
|
|
1101
|
+
const canReuseDataFlowDependenciesReport = !missingDataFlowReport &&
|
|
1102
|
+
canReusePreviousDataFlowDependencies(previousPages, previousArtifacts, pageResults, artifactResults);
|
|
1103
|
+
const dataFlowSummaryReport = createDataFlowSummaryReport(manifest, routeWrites, artifactWrites);
|
|
1104
|
+
const dataFlowDependenciesReport = canReuseDataFlowDependenciesReport
|
|
1105
|
+
? undefined
|
|
1106
|
+
: createDataFlowDependenciesReport(manifest);
|
|
1107
|
+
if (shouldWriteSitemaps) {
|
|
1108
|
+
await writeSitemaps(config.outDir, manifest, config.site.baseUrl, trailingSlash, config.sitemap);
|
|
1109
|
+
}
|
|
1110
|
+
if (config.prefetch?.serviceWorker) {
|
|
1111
|
+
await writePrefetchAssets(config.outDir, config.prefetch);
|
|
1112
|
+
}
|
|
1113
|
+
const manifestPath = join(config.outDir, ".stoneage/manifest.json");
|
|
1114
|
+
const reportPath = join(config.outDir, ".stoneage/report.json");
|
|
1115
|
+
const metadataWrites = [
|
|
1116
|
+
writeJsonAndCache(manifestReadCache, manifestPath, compactManifest(manifest), manifest),
|
|
1117
|
+
writeJsonAndCache(buildReportReadCache, reportPath, report, report),
|
|
1118
|
+
writeJson(dataFlowSummaryReportPath, dataFlowSummaryReport),
|
|
1119
|
+
rm(legacyDataFlowReportPath, { force: true }),
|
|
1120
|
+
];
|
|
1121
|
+
if (dataFlowDependenciesReport) {
|
|
1122
|
+
metadataWrites.push(writeJson(dataFlowDependenciesReportPath, dataFlowDependenciesReport));
|
|
1123
|
+
}
|
|
1124
|
+
await Promise.all(metadataWrites);
|
|
1125
|
+
}
|
|
1126
|
+
else if (missingDataFlowReport) {
|
|
1127
|
+
const dataFlowSummaryReport = createDataFlowSummaryReport(manifest, routeWrites, artifactWrites);
|
|
1128
|
+
const dataFlowDependenciesReport = createDataFlowDependenciesReport(manifest);
|
|
1129
|
+
await writeJson(dataFlowSummaryReportPath, dataFlowSummaryReport);
|
|
1130
|
+
await writeJson(dataFlowDependenciesReportPath, dataFlowDependenciesReport);
|
|
1131
|
+
await rm(legacyDataFlowReportPath, { force: true });
|
|
1132
|
+
}
|
|
1133
|
+
if (config.publishing) {
|
|
1134
|
+
await writePublishingManifests(config.outDir, config.publishing, config.site.baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssets.headers);
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
pagesWritten,
|
|
1138
|
+
artifactsWritten,
|
|
1139
|
+
publicAssetsCopied,
|
|
1140
|
+
publicAssetsSkipped,
|
|
1141
|
+
manifest,
|
|
1142
|
+
report,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
async function buildNotFoundPage(config, notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets) {
|
|
1146
|
+
const metadata = {
|
|
1147
|
+
...resolvePageMetadata(undefined, undefined, notFound.metadata),
|
|
1148
|
+
// A 404 page must never be indexed or advertised in the sitemap.
|
|
1149
|
+
noindex: true,
|
|
1150
|
+
sitemap: false,
|
|
1151
|
+
};
|
|
1152
|
+
// `/404.html` names a concrete file, so its canonical URL must not gain the
|
|
1153
|
+
// trailing slash that `absoluteUrl` would append under trailingSlash "always".
|
|
1154
|
+
const canonical = metadata.canonical ?? `${config.site.baseUrl.replace(/\/$/, "")}${notFoundPath}`;
|
|
1155
|
+
const absoluteOutputPath = join(config.outDir, notFoundOutputPath);
|
|
1156
|
+
const dependencies = await hashDependencies([
|
|
1157
|
+
{
|
|
1158
|
+
key: internalPageFingerprintKey,
|
|
1159
|
+
hash: pageFingerprintHash(notFoundRouteName, notFoundPath, notFoundOutputPath, canonical, metadata, sharedPageFingerprintHash),
|
|
1160
|
+
},
|
|
1161
|
+
]);
|
|
1162
|
+
const previousPage = previousPages.get(notFoundPath);
|
|
1163
|
+
if (previousPage && sameDependencies(previousPage.dependencies, dependencies)) {
|
|
1164
|
+
return {
|
|
1165
|
+
page: previousPage,
|
|
1166
|
+
validationPage: validationPageFor(notFoundPath, canonical, metadata, config.site),
|
|
1167
|
+
pagesWritten: 0,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => notFound.render());
|
|
1171
|
+
const html = renderDocument(config.site, metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
|
|
1172
|
+
await writeText(absoluteOutputPath, html, {
|
|
1173
|
+
compareExisting: compareExistingGeneratedOutput,
|
|
1174
|
+
});
|
|
1175
|
+
return {
|
|
1176
|
+
page: {
|
|
1177
|
+
route: notFoundRouteName,
|
|
1178
|
+
path: notFoundPath,
|
|
1179
|
+
outputPath: notFoundOutputPath,
|
|
1180
|
+
dependencies,
|
|
1181
|
+
links: extractInternalLinks(html, trailingSlash),
|
|
1182
|
+
htmlBytes: Buffer.byteLength(html),
|
|
1183
|
+
sitemap: false,
|
|
1184
|
+
},
|
|
1185
|
+
validationPage: validationPageFor(notFoundPath, canonical, metadata, config.site),
|
|
1186
|
+
pagesWritten: 1,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function mergePartialPages(previousPages, changedPages) {
|
|
1190
|
+
const changedByPath = new Map(changedPages.map((page) => [page.path, page]));
|
|
1191
|
+
const merged = [];
|
|
1192
|
+
for (const previousPage of previousPages) {
|
|
1193
|
+
const changedPage = changedByPath.get(previousPage.path);
|
|
1194
|
+
if (changedPage) {
|
|
1195
|
+
merged.push(changedPage);
|
|
1196
|
+
changedByPath.delete(previousPage.path);
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
merged.push(previousPage);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
merged.push(...changedByPath.values());
|
|
1203
|
+
return merged;
|
|
1204
|
+
}
|
|
1205
|
+
function mergePartialArtifacts(previousArtifacts, changedArtifacts) {
|
|
1206
|
+
const changedByPath = new Map(changedArtifacts.map((artifact) => [artifact.path, artifact]));
|
|
1207
|
+
const merged = [];
|
|
1208
|
+
for (const previousArtifact of previousArtifacts) {
|
|
1209
|
+
const changedArtifact = changedByPath.get(previousArtifact.path);
|
|
1210
|
+
if (changedArtifact) {
|
|
1211
|
+
merged.push(changedArtifact);
|
|
1212
|
+
changedByPath.delete(previousArtifact.path);
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
merged.push(previousArtifact);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
merged.push(...changedByPath.values());
|
|
1219
|
+
return merged;
|
|
1220
|
+
}
|
|
1221
|
+
async function removeStaleGeneratedOutputs(outDir, previousManifest, manifest) {
|
|
1222
|
+
const currentOutputPaths = new Set(generatedOutputPaths(manifest));
|
|
1223
|
+
const staleOutputPaths = generatedOutputPaths(previousManifest).filter((outputPath) => !currentOutputPaths.has(outputPath));
|
|
1224
|
+
const uniqueStaleOutputPaths = [...new Set(staleOutputPaths)];
|
|
1225
|
+
await Promise.all(uniqueStaleOutputPaths.map((outputPath) => rm(join(outDir, outputPath), { force: true })));
|
|
1226
|
+
return uniqueStaleOutputPaths.length;
|
|
1227
|
+
}
|
|
1228
|
+
async function removeStalePublicAssetOutputs(outDir, previousPublicAssets, publicAssets) {
|
|
1229
|
+
const currentOutputPaths = new Set(publicAssets.map(publicAssetOutputPath));
|
|
1230
|
+
const staleOutputPaths = previousPublicAssets.flatMap((publicPath) => {
|
|
1231
|
+
const outputPath = safeReportedPublicAssetOutputPath(publicPath);
|
|
1232
|
+
return outputPath && !currentOutputPaths.has(outputPath) ? [outputPath] : [];
|
|
1233
|
+
});
|
|
1234
|
+
const uniqueStaleOutputPaths = [...new Set(staleOutputPaths)];
|
|
1235
|
+
await Promise.all(uniqueStaleOutputPaths.map((outputPath) => rm(join(outDir, outputPath), { force: true })));
|
|
1236
|
+
return uniqueStaleOutputPaths.length;
|
|
1237
|
+
}
|
|
1238
|
+
function generatedOutputPaths(manifest) {
|
|
1239
|
+
return [
|
|
1240
|
+
...manifest.pages.map((page) => page.outputPath),
|
|
1241
|
+
...manifest.artifacts.map((artifact) => artifact.outputPath),
|
|
1242
|
+
];
|
|
1243
|
+
}
|
|
1244
|
+
function managedPublicAssetPaths(assets) {
|
|
1245
|
+
return assets.map((asset) => `/${publicAssetOutputPath(asset.publicPath)}`);
|
|
1246
|
+
}
|
|
1247
|
+
function mergePublicAssets(previousPublicAssets, publicAssets) {
|
|
1248
|
+
return [...new Set([...previousPublicAssets, ...publicAssets])];
|
|
1249
|
+
}
|
|
1250
|
+
function publicAssetOutputPath(publicPath) {
|
|
1251
|
+
const [path] = publicPath.split(/[?#]/, 1);
|
|
1252
|
+
return posix.normalize(`/${path ?? ""}`).replace(/^\/+/, "");
|
|
1253
|
+
}
|
|
1254
|
+
function safeReportedPublicAssetOutputPath(publicPath) {
|
|
1255
|
+
const [path = ""] = publicPath.split(/[?#]/, 1);
|
|
1256
|
+
if (!path || path.endsWith("/") || path.split("/").includes("..")) {
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
const outputPath = publicAssetOutputPath(path);
|
|
1260
|
+
return outputPath ? outputPath : undefined;
|
|
1261
|
+
}
|
|
1262
|
+
function countWrites(items, routeOf, writesOf) {
|
|
1263
|
+
const writes = {};
|
|
1264
|
+
for (const item of items) {
|
|
1265
|
+
const count = writesOf(item);
|
|
1266
|
+
if (count === 0) {
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const route = routeOf(item);
|
|
1270
|
+
writes[route] = (writes[route] ?? 0) + count;
|
|
1271
|
+
}
|
|
1272
|
+
return writes;
|
|
1273
|
+
}
|
|
1274
|
+
function validateOutputPlan(pages, artifacts, hasNotFoundPage) {
|
|
1275
|
+
assertUniquePlanPaths(pages, (page) => page.path, (page) => `page route ${page.routeName} at ${page.path}`, "Duplicate page path");
|
|
1276
|
+
assertUniquePlanPaths(artifacts, (artifact) => artifact.path, (artifact) => `artifact route ${artifact.routeName} at ${artifact.path}`, "Duplicate artifact path");
|
|
1277
|
+
assertUniquePlanPaths([
|
|
1278
|
+
...pages.map((page) => ({
|
|
1279
|
+
kind: "page",
|
|
1280
|
+
routeName: page.routeName,
|
|
1281
|
+
path: page.path,
|
|
1282
|
+
outputPath: page.outputPath,
|
|
1283
|
+
})),
|
|
1284
|
+
...artifacts.map((artifact) => ({
|
|
1285
|
+
kind: "artifact",
|
|
1286
|
+
routeName: artifact.routeName,
|
|
1287
|
+
path: artifact.path,
|
|
1288
|
+
outputPath: artifact.outputPath,
|
|
1289
|
+
})),
|
|
1290
|
+
// The built-in not-found page writes a fixed file at the output root; a
|
|
1291
|
+
// user page or artifact that targets the same file would silently clobber
|
|
1292
|
+
// it, so include it in the output-path collision check.
|
|
1293
|
+
...(hasNotFoundPage
|
|
1294
|
+
? [
|
|
1295
|
+
{
|
|
1296
|
+
kind: "not-found page",
|
|
1297
|
+
routeName: notFoundRouteName,
|
|
1298
|
+
path: notFoundPath,
|
|
1299
|
+
outputPath: notFoundOutputPath,
|
|
1300
|
+
},
|
|
1301
|
+
]
|
|
1302
|
+
: []),
|
|
1303
|
+
], (item) => item.outputPath, (item) => `${item.kind} route ${item.routeName} at ${item.path}`, "Output path collision");
|
|
1304
|
+
}
|
|
1305
|
+
function validateParamMatchers(kind, familyName, params, matchers) {
|
|
1306
|
+
for (const [name, matcher] of Object.entries(matchers ?? {})) {
|
|
1307
|
+
if (!matcher) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const value = params[name];
|
|
1311
|
+
if (value === undefined || value === null) {
|
|
1312
|
+
throw new Error(`${kind} family ${familyName} is missing param for matcher: ${name}`);
|
|
1313
|
+
}
|
|
1314
|
+
const stringValue = String(value);
|
|
1315
|
+
if (!matcher(stringValue)) {
|
|
1316
|
+
throw new Error(`${kind} family ${familyName} param ${name} value ${JSON.stringify(stringValue)} failed its matcher.`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function assertUniquePlanPaths(items, keyOf, labelOf, errorPrefix) {
|
|
1321
|
+
const seen = new Map();
|
|
1322
|
+
for (const item of items) {
|
|
1323
|
+
const key = keyOf(item);
|
|
1324
|
+
const existing = seen.get(key);
|
|
1325
|
+
if (existing) {
|
|
1326
|
+
throw new Error(`${errorPrefix}: ${key} is produced by ${labelOf(existing)} and ${labelOf(item)}.`);
|
|
1327
|
+
}
|
|
1328
|
+
seen.set(key, item);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function resolvePageMetadata(routeDefaults, pathDefaults, pageMetadata) {
|
|
1332
|
+
return {
|
|
1333
|
+
title: "",
|
|
1334
|
+
description: "",
|
|
1335
|
+
...(routeDefaults ?? {}),
|
|
1336
|
+
...(pathDefaults ?? {}),
|
|
1337
|
+
...(pageMetadata ?? {}),
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function pageFingerprintHash(route, path, outputPath, canonical, metadata, sharedPageFingerprintHash, declaredLinks) {
|
|
1341
|
+
const parts = [
|
|
1342
|
+
route,
|
|
1343
|
+
path,
|
|
1344
|
+
outputPath,
|
|
1345
|
+
canonical,
|
|
1346
|
+
pageMetadataFingerprint(metadata),
|
|
1347
|
+
sharedPageFingerprintHash,
|
|
1348
|
+
];
|
|
1349
|
+
if (declaredLinks !== undefined) {
|
|
1350
|
+
parts.push(stablePrimitiveArrayFingerprint(declaredLinks));
|
|
1351
|
+
}
|
|
1352
|
+
return hashText(parts.join("\0"));
|
|
1353
|
+
}
|
|
1354
|
+
function normalizeDeclaredInternalLinks(links, trailingSlash) {
|
|
1355
|
+
if (areDeclaredInternalLinksNormalized(links, trailingSlash)) {
|
|
1356
|
+
return [...links];
|
|
1357
|
+
}
|
|
1358
|
+
const normalized = new Set();
|
|
1359
|
+
for (const href of links) {
|
|
1360
|
+
if (!href.startsWith("/") || href.startsWith("//")) {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
normalized.add(normalizeInternalHref(href, trailingSlash));
|
|
1364
|
+
}
|
|
1365
|
+
return [...normalized].sort();
|
|
1366
|
+
}
|
|
1367
|
+
function areDeclaredInternalLinksNormalized(links, trailingSlash) {
|
|
1368
|
+
let previous = "";
|
|
1369
|
+
for (let index = 0; index < links.length; index += 1) {
|
|
1370
|
+
const link = links[index];
|
|
1371
|
+
if (!isNormalizedInternalHref(link, trailingSlash)) {
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
if (index > 0 && previous >= link) {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
previous = link;
|
|
1378
|
+
}
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
function isNormalizedInternalHref(href, trailingSlash) {
|
|
1382
|
+
if (!href.startsWith("/") || href.startsWith("//") || /[?#]/.test(href)) {
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
if (href.includes("//")) {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
if (href === "/") {
|
|
1389
|
+
return true;
|
|
1390
|
+
}
|
|
1391
|
+
if (hasPathExtension(href)) {
|
|
1392
|
+
return !href.endsWith("/");
|
|
1393
|
+
}
|
|
1394
|
+
return trailingSlash === "never" ? !href.endsWith("/") : href.endsWith("/");
|
|
1395
|
+
}
|
|
1396
|
+
const pageMetadataFingerprintKeys = [
|
|
1397
|
+
"assets",
|
|
1398
|
+
"canonical",
|
|
1399
|
+
"description",
|
|
1400
|
+
"head",
|
|
1401
|
+
"islands",
|
|
1402
|
+
"noindex",
|
|
1403
|
+
"ogDescription",
|
|
1404
|
+
"ogImage",
|
|
1405
|
+
"ogImageHeight",
|
|
1406
|
+
"ogImageWidth",
|
|
1407
|
+
"ogTitle",
|
|
1408
|
+
"ogType",
|
|
1409
|
+
"openGraph",
|
|
1410
|
+
"prefetch",
|
|
1411
|
+
"sitemap",
|
|
1412
|
+
"title",
|
|
1413
|
+
"twitter",
|
|
1414
|
+
];
|
|
1415
|
+
const pageMetadataFingerprintKeySet = new Set(pageMetadataFingerprintKeys);
|
|
1416
|
+
function pageMetadataFingerprint(metadata) {
|
|
1417
|
+
const extraKeys = [];
|
|
1418
|
+
for (const key in metadata) {
|
|
1419
|
+
if (Object.prototype.hasOwnProperty.call(metadata, key) &&
|
|
1420
|
+
!pageMetadataFingerprintKeySet.has(key)) {
|
|
1421
|
+
extraKeys.push(key);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (extraKeys.length === 0) {
|
|
1425
|
+
return pageMetadataFingerprintForKeys(metadata, pageMetadataFingerprintKeys);
|
|
1426
|
+
}
|
|
1427
|
+
return pageMetadataFingerprintForKeys(metadata, [...pageMetadataFingerprintKeys, ...extraKeys].sort((left, right) => left.localeCompare(right)));
|
|
1428
|
+
}
|
|
1429
|
+
function pageMetadataFingerprintForKeys(metadata, keys) {
|
|
1430
|
+
const metadataRecord = metadata;
|
|
1431
|
+
const parts = [];
|
|
1432
|
+
for (const key of keys) {
|
|
1433
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, key)) {
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
parts.push(`${JSON.stringify(key)}:${pageMetadataFieldFingerprint(metadataRecord[key])}`);
|
|
1437
|
+
}
|
|
1438
|
+
return `{${parts.join(",")}}`;
|
|
1439
|
+
}
|
|
1440
|
+
function pageMetadataFieldFingerprint(value) {
|
|
1441
|
+
if (isStablePrimitiveArray(value)) {
|
|
1442
|
+
return stablePrimitiveArrayFingerprint(value);
|
|
1443
|
+
}
|
|
1444
|
+
if (value === null || typeof value !== "object") {
|
|
1445
|
+
return JSON.stringify(value);
|
|
1446
|
+
}
|
|
1447
|
+
return stableJson(value);
|
|
1448
|
+
}
|
|
1449
|
+
function stablePrimitiveArrayFingerprint(value) {
|
|
1450
|
+
const parts = [];
|
|
1451
|
+
for (const item of value) {
|
|
1452
|
+
parts.push(JSON.stringify(item));
|
|
1453
|
+
}
|
|
1454
|
+
return `[${parts.join(",")}]`;
|
|
1455
|
+
}
|
|
1456
|
+
function isStablePrimitiveArray(value) {
|
|
1457
|
+
return (Array.isArray(value) &&
|
|
1458
|
+
value.every((item) => item === null || (typeof item !== "object" && typeof item !== "function")));
|
|
1459
|
+
}
|
|
1460
|
+
function artifactFingerprintHash(route, path, outputPath, renderFingerprint, fragmentDefaultFallbackText) {
|
|
1461
|
+
return hashText([route, path, outputPath, renderFingerprint ?? "", fragmentDefaultFallbackText ?? ""].join("\0"));
|
|
1462
|
+
}
|
|
1463
|
+
async function mapConcurrent(items, concurrency, worker) {
|
|
1464
|
+
const limit = Math.max(1, Math.floor(concurrency));
|
|
1465
|
+
const results = new Array(items.length);
|
|
1466
|
+
let nextIndex = 0;
|
|
1467
|
+
async function runWorker() {
|
|
1468
|
+
while (nextIndex < items.length) {
|
|
1469
|
+
const index = nextIndex;
|
|
1470
|
+
nextIndex += 1;
|
|
1471
|
+
results[index] = await worker(items[index]);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
|
|
1475
|
+
return results;
|
|
1476
|
+
}
|
|
1477
|
+
async function readManifest(outDir) {
|
|
1478
|
+
const path = join(outDir, ".stoneage/manifest.json");
|
|
1479
|
+
const cached = await readGeneratedJsonCache(manifestReadCache, path);
|
|
1480
|
+
if (cached) {
|
|
1481
|
+
return cached;
|
|
1482
|
+
}
|
|
1483
|
+
try {
|
|
1484
|
+
const raw = await readFile(path, "utf8");
|
|
1485
|
+
const parsed = JSON.parse(raw);
|
|
1486
|
+
if (parsed.version === 1 && Array.isArray(parsed.pages)) {
|
|
1487
|
+
return await cacheGeneratedJson(manifestReadCache, path, {
|
|
1488
|
+
...parsed,
|
|
1489
|
+
artifacts: parsed.artifacts ?? [],
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
if (parsed.version === 2 && Array.isArray(parsed.pages)) {
|
|
1493
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV2(parsed));
|
|
1494
|
+
}
|
|
1495
|
+
if (parsed.version === 3 && Array.isArray(parsed.pages)) {
|
|
1496
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV3(parsed));
|
|
1497
|
+
}
|
|
1498
|
+
if (parsed.version === 4 && Array.isArray(parsed.pages) && Array.isArray(parsed.strings)) {
|
|
1499
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV4(parsed));
|
|
1500
|
+
}
|
|
1501
|
+
if (parsed.version === 5 && Array.isArray(parsed.pages) && Array.isArray(parsed.strings)) {
|
|
1502
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV5(parsed));
|
|
1503
|
+
}
|
|
1504
|
+
if (parsed.version === 6 && Array.isArray(parsed.pages) && Array.isArray(parsed.strings)) {
|
|
1505
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV6(parsed));
|
|
1506
|
+
}
|
|
1507
|
+
if (parsed.version === 7 &&
|
|
1508
|
+
Array.isArray(parsed.pages) &&
|
|
1509
|
+
Array.isArray(parsed.artifacts) &&
|
|
1510
|
+
Array.isArray(parsed.strings)) {
|
|
1511
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV7(parsed));
|
|
1512
|
+
}
|
|
1513
|
+
if (parsed.version === 8 &&
|
|
1514
|
+
Array.isArray(parsed.pages) &&
|
|
1515
|
+
Array.isArray(parsed.artifacts) &&
|
|
1516
|
+
Array.isArray(parsed.strings)) {
|
|
1517
|
+
return await cacheGeneratedJson(manifestReadCache, path, expandManifestV8(parsed));
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
// A missing or invalid cache is equivalent to a clean build.
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
version: 1,
|
|
1525
|
+
pages: [],
|
|
1526
|
+
artifacts: [],
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
async function readBuildReport(outDir) {
|
|
1530
|
+
const path = join(outDir, ".stoneage/report.json");
|
|
1531
|
+
const cached = await readGeneratedJsonCache(buildReportReadCache, path);
|
|
1532
|
+
if (cached) {
|
|
1533
|
+
return cached;
|
|
1534
|
+
}
|
|
1535
|
+
try {
|
|
1536
|
+
return await cacheGeneratedJson(buildReportReadCache, path, JSON.parse(await readFile(path, "utf8")));
|
|
1537
|
+
}
|
|
1538
|
+
catch (error) {
|
|
1539
|
+
if (isMissingPath(error)) {
|
|
1540
|
+
return undefined;
|
|
1541
|
+
}
|
|
1542
|
+
throw error;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
async function readGeneratedJsonCache(cache, path) {
|
|
1546
|
+
const cached = cache.get(path);
|
|
1547
|
+
if (!cached) {
|
|
1548
|
+
return undefined;
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
const stats = await stat(path);
|
|
1552
|
+
if (stats.mtimeMs === cached.mtimeMs && stats.size === cached.size) {
|
|
1553
|
+
return cached.value;
|
|
1554
|
+
}
|
|
1555
|
+
cache.delete(path);
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
if (isMissingPath(error)) {
|
|
1560
|
+
cache.delete(path);
|
|
1561
|
+
return undefined;
|
|
1562
|
+
}
|
|
1563
|
+
throw error;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
async function cacheGeneratedJson(cache, path, value) {
|
|
1567
|
+
const stats = await stat(path);
|
|
1568
|
+
cache.set(path, {
|
|
1569
|
+
mtimeMs: stats.mtimeMs,
|
|
1570
|
+
size: stats.size,
|
|
1571
|
+
value,
|
|
1572
|
+
});
|
|
1573
|
+
return value;
|
|
1574
|
+
}
|
|
1575
|
+
async function writeJsonAndCache(cache, path, persistedValue, expandedValue) {
|
|
1576
|
+
await writeJson(path, persistedValue);
|
|
1577
|
+
return await cacheGeneratedJson(cache, path, expandedValue);
|
|
1578
|
+
}
|
|
1579
|
+
function compactManifest(manifest) {
|
|
1580
|
+
const strings = [];
|
|
1581
|
+
const stringIds = new Map();
|
|
1582
|
+
const intern = (value) => {
|
|
1583
|
+
const current = stringIds.get(value);
|
|
1584
|
+
if (current !== undefined) {
|
|
1585
|
+
return current;
|
|
1586
|
+
}
|
|
1587
|
+
const next = strings.length;
|
|
1588
|
+
strings.push(value);
|
|
1589
|
+
stringIds.set(value, next);
|
|
1590
|
+
return next;
|
|
1591
|
+
};
|
|
1592
|
+
return {
|
|
1593
|
+
version: 8,
|
|
1594
|
+
strings,
|
|
1595
|
+
pages: manifest.pages.map((page) => {
|
|
1596
|
+
const persisted = [
|
|
1597
|
+
intern(page.route),
|
|
1598
|
+
intern(page.path),
|
|
1599
|
+
Object.entries(page.dependencies).map(([key, hash]) => [intern(key), hash]),
|
|
1600
|
+
page.links.map((link) => intern(link)),
|
|
1601
|
+
page.htmlBytes,
|
|
1602
|
+
];
|
|
1603
|
+
if (!page.sitemap) {
|
|
1604
|
+
persisted.push(0);
|
|
1605
|
+
}
|
|
1606
|
+
return persisted;
|
|
1607
|
+
}),
|
|
1608
|
+
artifacts: manifest.artifacts.map((artifact) => {
|
|
1609
|
+
const persisted = [
|
|
1610
|
+
intern(artifact.route),
|
|
1611
|
+
intern(artifact.path),
|
|
1612
|
+
Object.entries(artifact.dependencies).map(([key, hash]) => [intern(key), hash]),
|
|
1613
|
+
artifact.bytes,
|
|
1614
|
+
];
|
|
1615
|
+
const metadata = persistedArtifactMetadata(artifact);
|
|
1616
|
+
if (metadata) {
|
|
1617
|
+
persisted.push(metadata);
|
|
1618
|
+
}
|
|
1619
|
+
return persisted;
|
|
1620
|
+
}),
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function persistedArtifactMetadata(artifact) {
|
|
1624
|
+
const metadata = {
|
|
1625
|
+
...(artifact.contentType ? { contentType: artifact.contentType } : {}),
|
|
1626
|
+
...(artifact.status !== undefined ? { status: artifact.status } : {}),
|
|
1627
|
+
...(artifact.headers ? { headers: artifact.headers } : {}),
|
|
1628
|
+
};
|
|
1629
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
1630
|
+
}
|
|
1631
|
+
function expandManifestV2(manifest) {
|
|
1632
|
+
return {
|
|
1633
|
+
version: 1,
|
|
1634
|
+
pages: manifest.pages.map(([route, path, outputPath, dependencies, links, htmlBytes]) => ({
|
|
1635
|
+
route,
|
|
1636
|
+
path,
|
|
1637
|
+
outputPath,
|
|
1638
|
+
dependencies,
|
|
1639
|
+
links,
|
|
1640
|
+
htmlBytes,
|
|
1641
|
+
sitemap: true,
|
|
1642
|
+
})),
|
|
1643
|
+
artifacts: [],
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
function expandManifestV3(manifest) {
|
|
1647
|
+
return {
|
|
1648
|
+
version: 1,
|
|
1649
|
+
pages: manifest.pages.map(([route, path, dependencies, links, htmlBytes]) => ({
|
|
1650
|
+
route,
|
|
1651
|
+
path,
|
|
1652
|
+
outputPath: outputPathFor(path),
|
|
1653
|
+
dependencies,
|
|
1654
|
+
links,
|
|
1655
|
+
htmlBytes,
|
|
1656
|
+
sitemap: true,
|
|
1657
|
+
})),
|
|
1658
|
+
artifacts: [],
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
function expandManifestV4(manifest) {
|
|
1662
|
+
const readString = (index) => manifest.strings[index] ?? "";
|
|
1663
|
+
return {
|
|
1664
|
+
version: 1,
|
|
1665
|
+
pages: manifest.pages.map(([route, path, dependencies, links, htmlBytes]) => {
|
|
1666
|
+
const pagePath = readString(path);
|
|
1667
|
+
return {
|
|
1668
|
+
route: readString(route),
|
|
1669
|
+
path: pagePath,
|
|
1670
|
+
outputPath: outputPathFor(pagePath),
|
|
1671
|
+
dependencies: Object.fromEntries(dependencies.map(([key, hash]) => [readString(key), hash])),
|
|
1672
|
+
links: links.map((link) => readString(link)),
|
|
1673
|
+
htmlBytes,
|
|
1674
|
+
sitemap: true,
|
|
1675
|
+
};
|
|
1676
|
+
}),
|
|
1677
|
+
artifacts: [],
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
function expandManifestV5(manifest) {
|
|
1681
|
+
const readString = (index) => manifest.strings[index] ?? "";
|
|
1682
|
+
return {
|
|
1683
|
+
version: 1,
|
|
1684
|
+
pages: manifest.pages.map(([route, path, dependencies, links, htmlBytes, sitemap]) => {
|
|
1685
|
+
const pagePath = readString(path);
|
|
1686
|
+
return {
|
|
1687
|
+
route: readString(route),
|
|
1688
|
+
path: pagePath,
|
|
1689
|
+
outputPath: outputPathFor(pagePath),
|
|
1690
|
+
dependencies: Object.fromEntries(dependencies.map(([key, hash]) => [readString(key), hash])),
|
|
1691
|
+
links: links.map((link) => readString(link)),
|
|
1692
|
+
htmlBytes,
|
|
1693
|
+
sitemap: sitemap === 1,
|
|
1694
|
+
};
|
|
1695
|
+
}),
|
|
1696
|
+
artifacts: [],
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function expandManifestV6(manifest) {
|
|
1700
|
+
const readString = (index) => manifest.strings[index] ?? "";
|
|
1701
|
+
return {
|
|
1702
|
+
version: 1,
|
|
1703
|
+
pages: manifest.pages.map(([route, path, dependencies, links, htmlBytes, sitemap]) => {
|
|
1704
|
+
const pagePath = readString(path);
|
|
1705
|
+
const pageRoute = readString(route);
|
|
1706
|
+
return {
|
|
1707
|
+
route: pageRoute,
|
|
1708
|
+
path: pagePath,
|
|
1709
|
+
// The synthetic not-found page is written to a fixed file at the output
|
|
1710
|
+
// root, so its output path is not derived from `outputPathFor`.
|
|
1711
|
+
outputPath: pageRoute === notFoundRouteName ? notFoundOutputPath : outputPathFor(pagePath),
|
|
1712
|
+
dependencies: Object.fromEntries(dependencies.map(([key, hash]) => [readString(key), hash])),
|
|
1713
|
+
links: links.map((link) => readString(link)),
|
|
1714
|
+
htmlBytes,
|
|
1715
|
+
sitemap: sitemap !== 0,
|
|
1716
|
+
};
|
|
1717
|
+
}),
|
|
1718
|
+
artifacts: [],
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function expandManifestV7(manifest) {
|
|
1722
|
+
const expanded = expandManifestV6({
|
|
1723
|
+
version: 6,
|
|
1724
|
+
strings: manifest.strings,
|
|
1725
|
+
pages: manifest.pages,
|
|
1726
|
+
});
|
|
1727
|
+
const readString = (index) => manifest.strings[index] ?? "";
|
|
1728
|
+
return {
|
|
1729
|
+
...expanded,
|
|
1730
|
+
artifacts: manifest.artifacts.map(([route, path, dependencies, bytes]) => {
|
|
1731
|
+
const artifactPath = readString(path);
|
|
1732
|
+
return {
|
|
1733
|
+
route: readString(route),
|
|
1734
|
+
path: artifactPath,
|
|
1735
|
+
outputPath: outputPathForArtifact(artifactPath),
|
|
1736
|
+
dependencies: Object.fromEntries(dependencies.map(([key, hash]) => [readString(key), hash])),
|
|
1737
|
+
bytes,
|
|
1738
|
+
};
|
|
1739
|
+
}),
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
function expandManifestV8(manifest) {
|
|
1743
|
+
const expanded = expandManifestV6({
|
|
1744
|
+
version: 6,
|
|
1745
|
+
strings: manifest.strings,
|
|
1746
|
+
pages: manifest.pages,
|
|
1747
|
+
});
|
|
1748
|
+
const readString = (index) => manifest.strings[index] ?? "";
|
|
1749
|
+
return {
|
|
1750
|
+
...expanded,
|
|
1751
|
+
artifacts: manifest.artifacts.map(([route, path, dependencies, bytes, metadata]) => {
|
|
1752
|
+
const artifactPath = readString(path);
|
|
1753
|
+
return {
|
|
1754
|
+
route: readString(route),
|
|
1755
|
+
path: artifactPath,
|
|
1756
|
+
outputPath: outputPathForArtifact(artifactPath),
|
|
1757
|
+
dependencies: Object.fromEntries(dependencies.map(([key, hash]) => [readString(key), hash])),
|
|
1758
|
+
bytes,
|
|
1759
|
+
...(metadata?.contentType ? { contentType: metadata.contentType } : {}),
|
|
1760
|
+
...(metadata?.status !== undefined ? { status: metadata.status } : {}),
|
|
1761
|
+
...(metadata?.headers ? { headers: metadata.headers } : {}),
|
|
1762
|
+
};
|
|
1763
|
+
}),
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
function hashDependencies(dependencies) {
|
|
1767
|
+
const precomputed = hashPrecomputedDependencies(dependencies);
|
|
1768
|
+
if (precomputed) {
|
|
1769
|
+
return precomputed;
|
|
1770
|
+
}
|
|
1771
|
+
return hashDynamicDependencies(dependencies);
|
|
1772
|
+
}
|
|
1773
|
+
function hashPrecomputedDependencies(dependencies) {
|
|
1774
|
+
const hashes = Object.create(null);
|
|
1775
|
+
for (const dependency of dependencies) {
|
|
1776
|
+
if (typeof dependency === "string" || !("hash" in dependency)) {
|
|
1777
|
+
return undefined;
|
|
1778
|
+
}
|
|
1779
|
+
if (hashes[dependency.key] !== undefined) {
|
|
1780
|
+
throw new Error(`Duplicate dependency key: ${JSON.stringify(dependency.key)}`);
|
|
1781
|
+
}
|
|
1782
|
+
hashes[dependency.key] = dependency.hash;
|
|
1783
|
+
}
|
|
1784
|
+
return hashes;
|
|
1785
|
+
}
|
|
1786
|
+
async function hashDynamicDependencies(dependencies) {
|
|
1787
|
+
const hashes = {};
|
|
1788
|
+
for (const dependency of dependencies) {
|
|
1789
|
+
if (typeof dependency === "string") {
|
|
1790
|
+
setDependencyHash(hashes, dependency, hashText(dependency));
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
if ("file" in dependency) {
|
|
1794
|
+
const contents = await readFile(dependency.file);
|
|
1795
|
+
setDependencyHash(hashes, dependency.key, hashText(contents));
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if ("hash" in dependency) {
|
|
1799
|
+
setDependencyHash(hashes, dependency.key, dependency.hash);
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
setDependencyHash(hashes, dependency.key, hashValue(dependency.value));
|
|
1803
|
+
}
|
|
1804
|
+
return hashes;
|
|
1805
|
+
}
|
|
1806
|
+
function setDependencyHash(hashes, key, hash) {
|
|
1807
|
+
if (Object.prototype.hasOwnProperty.call(hashes, key)) {
|
|
1808
|
+
throw new Error(`Duplicate dependency key: ${JSON.stringify(key)}`);
|
|
1809
|
+
}
|
|
1810
|
+
hashes[key] = hash;
|
|
1811
|
+
}
|
|
1812
|
+
export function hashValue(value) {
|
|
1813
|
+
return hashText(stableJson(value));
|
|
1814
|
+
}
|
|
1815
|
+
function stableJson(value) {
|
|
1816
|
+
if (value === null || typeof value !== "object") {
|
|
1817
|
+
return JSON.stringify(value);
|
|
1818
|
+
}
|
|
1819
|
+
if (Array.isArray(value)) {
|
|
1820
|
+
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
1821
|
+
}
|
|
1822
|
+
return `{${Object.entries(value)
|
|
1823
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1824
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
1825
|
+
.join(",")}}`;
|
|
1826
|
+
}
|
|
1827
|
+
function sameDependencies(left, right) {
|
|
1828
|
+
let leftCount = 0;
|
|
1829
|
+
for (const key in left) {
|
|
1830
|
+
if (!Object.prototype.hasOwnProperty.call(left, key)) {
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
leftCount += 1;
|
|
1834
|
+
if (left[key] !== right[key]) {
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
let rightCount = 0;
|
|
1839
|
+
for (const key in right) {
|
|
1840
|
+
if (Object.prototype.hasOwnProperty.call(right, key)) {
|
|
1841
|
+
rightCount += 1;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return leftCount === rightCount;
|
|
1845
|
+
}
|
|
1846
|
+
function renderDocument(site, metadata, canonical, body, assets, prefetch, trailingSlash = "always", extraClientScripts = []) {
|
|
1847
|
+
const title = renderTitle(metadata.title, site);
|
|
1848
|
+
const description = metadata.description || site.description;
|
|
1849
|
+
const ogTitle = metadata.ogTitle ?? metadata.title;
|
|
1850
|
+
const ogDescription = metadata.ogDescription ?? description;
|
|
1851
|
+
const shouldRenderOpenGraph = metadata.openGraph ?? openGraphEnabled(site);
|
|
1852
|
+
const ogTitleMeta = shouldRenderOpenGraph
|
|
1853
|
+
? `<meta property="og:title" content="${escapeAttribute(ogTitle)}">`
|
|
1854
|
+
: "";
|
|
1855
|
+
const ogDescriptionMeta = shouldRenderOpenGraph
|
|
1856
|
+
? `<meta property="og:description" content="${escapeAttribute(ogDescription)}">`
|
|
1857
|
+
: "";
|
|
1858
|
+
const ogUrl = shouldRenderOpenGraph
|
|
1859
|
+
? `<meta property="og:url" content="${escapeAttribute(canonical)}">`
|
|
1860
|
+
: "";
|
|
1861
|
+
const ogSiteNameValue = siteOpenGraphMetadata(site).siteName;
|
|
1862
|
+
const ogSiteName = shouldRenderOpenGraph && ogSiteNameValue
|
|
1863
|
+
? `<meta property="og:site_name" content="${escapeAttribute(ogSiteNameValue)}">`
|
|
1864
|
+
: "";
|
|
1865
|
+
const favicon = site.favicon
|
|
1866
|
+
? `<link rel="icon" href="${escapeAttribute(site.favicon)}">`
|
|
1867
|
+
: "";
|
|
1868
|
+
const robots = metadata.noindex ? '<meta name="robots" content="noindex">' : "";
|
|
1869
|
+
const ogImageValue = metadata.ogImage ?? site.ogImage;
|
|
1870
|
+
const ogImage = shouldRenderOpenGraph && ogImageValue
|
|
1871
|
+
? `<meta property="og:image" content="${escapeAttribute(ogImageValue)}">`
|
|
1872
|
+
: "";
|
|
1873
|
+
const ogImageWidth = metadata.ogImageWidth ?? site.ogImageWidth;
|
|
1874
|
+
const ogImageWidthMeta = shouldRenderOpenGraph && ogImageWidth !== undefined
|
|
1875
|
+
? `<meta property="og:image:width" content="${escapeAttribute(String(ogImageWidth))}">`
|
|
1876
|
+
: "";
|
|
1877
|
+
const ogImageHeight = metadata.ogImageHeight ?? site.ogImageHeight;
|
|
1878
|
+
const ogImageHeightMeta = shouldRenderOpenGraph && ogImageHeight !== undefined
|
|
1879
|
+
? `<meta property="og:image:height" content="${escapeAttribute(String(ogImageHeight))}">`
|
|
1880
|
+
: "";
|
|
1881
|
+
const ogType = shouldRenderOpenGraph && metadata.ogType
|
|
1882
|
+
? `<meta property="og:type" content="${escapeAttribute(metadata.ogType)}">`
|
|
1883
|
+
: "";
|
|
1884
|
+
const twitterMeta = renderTwitterMetadata(site.twitter, metadata.twitter);
|
|
1885
|
+
const customHead = [...(site.head ?? []), ...(metadata.head ?? [])].map(renderHeadTag).join("");
|
|
1886
|
+
const pageClientAssets = resolvePageClientAssets(assets, metadata);
|
|
1887
|
+
const stylesheetLinks = (assets?.stylesheets ?? [])
|
|
1888
|
+
.concat(pageClientAssets.stylesheets)
|
|
1889
|
+
.map((stylesheet) => `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
|
|
1890
|
+
.join("");
|
|
1891
|
+
const clientScripts = renderClientScripts([
|
|
1892
|
+
...extraClientScripts,
|
|
1893
|
+
...pageClientAssets.scripts,
|
|
1894
|
+
]);
|
|
1895
|
+
const prefetchUrls = normalizePrefetchUrls(metadata.prefetch ?? [], trailingSlash);
|
|
1896
|
+
const prefetchLinks = prefetch?.linkRel === false
|
|
1897
|
+
? ""
|
|
1898
|
+
: prefetchUrls
|
|
1899
|
+
.map((href) => `<link rel="prefetch" href="${escapeAttribute(href)}">`)
|
|
1900
|
+
.join("");
|
|
1901
|
+
const serviceWorkerPrefetch = prefetch?.serviceWorker
|
|
1902
|
+
? renderServiceWorkerPrefetch(prefetchUrls, prefetch)
|
|
1903
|
+
: "";
|
|
1904
|
+
return `<!doctype html><html lang="${escapeAttribute(site.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><meta name="description" content="${escapeAttribute(description)}"><link rel="canonical" href="${escapeAttribute(canonical)}">${robots}${favicon}${ogTitleMeta}${ogDescriptionMeta}${ogUrl}${ogSiteName}${ogType}${ogImage}${ogImageWidthMeta}${ogImageHeightMeta}${twitterMeta}${customHead}${stylesheetLinks}${clientScripts}${prefetchLinks}${serviceWorkerPrefetch}<body>${body}`;
|
|
1905
|
+
}
|
|
1906
|
+
function renderTitle(pageTitle, site) {
|
|
1907
|
+
return site.titleTemplate
|
|
1908
|
+
? site.titleTemplate.replaceAll("%s", pageTitle)
|
|
1909
|
+
: `${pageTitle} | ${site.title}`;
|
|
1910
|
+
}
|
|
1911
|
+
function openGraphEnabled(site) {
|
|
1912
|
+
return site.openGraph === false ? false : true;
|
|
1913
|
+
}
|
|
1914
|
+
function siteOpenGraphMetadata(site) {
|
|
1915
|
+
return typeof site.openGraph === "object" ? site.openGraph : {};
|
|
1916
|
+
}
|
|
1917
|
+
function renderTwitterMetadata(siteTwitter, pageTwitter) {
|
|
1918
|
+
const twitter = { ...(siteTwitter ?? {}), ...(pageTwitter ?? {}) };
|
|
1919
|
+
return [
|
|
1920
|
+
twitter.card ? renderMetaName("twitter:card", twitter.card) : "",
|
|
1921
|
+
twitter.site ? renderMetaName("twitter:site", twitter.site) : "",
|
|
1922
|
+
twitter.creator ? renderMetaName("twitter:creator", twitter.creator) : "",
|
|
1923
|
+
twitter.title ? renderMetaName("twitter:title", twitter.title) : "",
|
|
1924
|
+
twitter.description ? renderMetaName("twitter:description", twitter.description) : "",
|
|
1925
|
+
twitter.image ? renderMetaName("twitter:image", twitter.image) : "",
|
|
1926
|
+
].join("");
|
|
1927
|
+
}
|
|
1928
|
+
function renderMetaName(name, content) {
|
|
1929
|
+
return `<meta name="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`;
|
|
1930
|
+
}
|
|
1931
|
+
function renderHeadTag(tag) {
|
|
1932
|
+
return `<${tag.tag}${renderHeadAttributes(tag.attrs)}>`;
|
|
1933
|
+
}
|
|
1934
|
+
function renderHeadAttributes(attrs) {
|
|
1935
|
+
return Object.entries(attrs)
|
|
1936
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1937
|
+
.map(([name, value]) => {
|
|
1938
|
+
if (value === false || value === null || value === undefined) {
|
|
1939
|
+
return "";
|
|
1940
|
+
}
|
|
1941
|
+
return value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`;
|
|
1942
|
+
})
|
|
1943
|
+
.join("");
|
|
1944
|
+
}
|
|
1945
|
+
function resolvePageClientAssets(assets, metadata) {
|
|
1946
|
+
const ids = [...(metadata.assets ?? []), ...(metadata.islands ?? [])];
|
|
1947
|
+
if (ids.length === 0) {
|
|
1948
|
+
return {
|
|
1949
|
+
stylesheets: [],
|
|
1950
|
+
scripts: [],
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
return resolveClientAssets(assets?.client ?? {}, ids);
|
|
1954
|
+
}
|
|
1955
|
+
function stylesheetHref(stylesheet) {
|
|
1956
|
+
return typeof stylesheet === "string" ? stylesheet : stylesheet.href;
|
|
1957
|
+
}
|
|
1958
|
+
function renderClientScripts(scripts) {
|
|
1959
|
+
return scripts.map((script) => `<script${renderScriptAttributes(script)}></script>`).join("");
|
|
1960
|
+
}
|
|
1961
|
+
function renderScriptAttributes(script) {
|
|
1962
|
+
let output = script.module ? ' type="module"' : "";
|
|
1963
|
+
output += ` src="${escapeAttribute(script.src)}"`;
|
|
1964
|
+
if (script.defer) {
|
|
1965
|
+
output += " defer";
|
|
1966
|
+
}
|
|
1967
|
+
if (script.async) {
|
|
1968
|
+
output += " async";
|
|
1969
|
+
}
|
|
1970
|
+
for (const [name, value] of Object.entries(script.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
|
1971
|
+
if (value === false || value === null || value === undefined) {
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
output += value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`;
|
|
1975
|
+
}
|
|
1976
|
+
return output;
|
|
1977
|
+
}
|
|
1978
|
+
function normalizePrefetchUrls(urls, trailingSlash) {
|
|
1979
|
+
return [
|
|
1980
|
+
...new Set(urls.map((url) => url.startsWith("/") && !url.startsWith("//")
|
|
1981
|
+
? normalizeInternalHref(url, trailingSlash)
|
|
1982
|
+
: url)),
|
|
1983
|
+
];
|
|
1984
|
+
}
|
|
1985
|
+
function renderServiceWorkerPrefetch(urls, prefetch) {
|
|
1986
|
+
if (urls.length === 0) {
|
|
1987
|
+
return "";
|
|
1988
|
+
}
|
|
1989
|
+
const scriptPath = prefetch.scriptPath ?? "/stoneage-prefetch.js";
|
|
1990
|
+
const prefetchUrls = [...new Set(urls)].join(" ");
|
|
1991
|
+
return `<script src="${escapeAttribute(scriptPath)}" data-prefetch="${escapeAttribute(prefetchUrls)}"></script>`;
|
|
1992
|
+
}
|
|
1993
|
+
async function writePrefetchAssets(outDir, prefetch) {
|
|
1994
|
+
const scriptPath = prefetch.scriptPath ?? "/stoneage-prefetch.js";
|
|
1995
|
+
const workerPath = prefetch.workerPath ?? "/stoneage-prefetch-sw.js";
|
|
1996
|
+
const cacheName = prefetch.cacheName ?? "stoneage-prefetch-v1";
|
|
1997
|
+
await Promise.all([
|
|
1998
|
+
writeText(join(outDir, publicOutputPathForAsset(scriptPath)), renderPrefetchScript(workerPath)),
|
|
1999
|
+
writeText(join(outDir, publicOutputPathForAsset(workerPath)), renderPrefetchWorker(cacheName)),
|
|
2000
|
+
]);
|
|
2001
|
+
}
|
|
2002
|
+
function renderPrefetchScript(workerPath) {
|
|
2003
|
+
return `const scripts=document.currentScript?[document.currentScript]:document.querySelectorAll("script[data-prefetch]");const urls=[...scripts].flatMap((script)=>(script.dataset.prefetch||"").split(/\\s+/).filter(Boolean));if(urls.length>0&&"serviceWorker"in navigator){navigator.serviceWorker.register("${escapeJavaScriptString(workerPath)}").then((registration)=>navigator.serviceWorker.ready.then(()=>{const worker=registration.active||navigator.serviceWorker.controller;if(worker)worker.postMessage({type:"STONEAGE_PREFETCH",urls});})).catch(()=>{});}`;
|
|
2004
|
+
}
|
|
2005
|
+
function renderPrefetchWorker(cacheName) {
|
|
2006
|
+
return `const CACHE_NAME="${escapeJavaScriptString(cacheName)}";self.addEventListener("message",(event)=>{const data=event.data;if(!data||data.type!=="STONEAGE_PREFETCH"||!Array.isArray(data.urls))return;event.waitUntil(caches.open(CACHE_NAME).then((cache)=>Promise.all(data.urls.filter((url)=>typeof url==="string"&&url.startsWith("/")).map((url)=>cache.add(url).catch(()=>undefined)))));});`;
|
|
2007
|
+
}
|
|
2008
|
+
async function writePublishingManifests(outDir, publishing, baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssetHeaders = []) {
|
|
2009
|
+
const redirects = normalizePublishingRedirects(publishing.redirects ?? [], trailingSlash);
|
|
2010
|
+
if (publishing.robots !== undefined && publishing.robots !== false) {
|
|
2011
|
+
await writeText(join(outDir, "robots.txt"), renderRobotsTxt(publishing.robots, baseUrl, trailingSlash));
|
|
2012
|
+
}
|
|
2013
|
+
if (redirects.length > 0) {
|
|
2014
|
+
await writeJson(join(outDir, "redirects.json"), {
|
|
2015
|
+
redirects,
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
for (const file of redirectHtmlFiles) {
|
|
2019
|
+
await writeText(join(outDir, file.outputPath), file.html);
|
|
2020
|
+
}
|
|
2021
|
+
let headers = [];
|
|
2022
|
+
if (publishing.headers === false) {
|
|
2023
|
+
await rm(join(outDir, "headers.json"), { force: true });
|
|
2024
|
+
}
|
|
2025
|
+
else {
|
|
2026
|
+
headers = await writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders);
|
|
2027
|
+
}
|
|
2028
|
+
await writeNativePublishingArtifacts(outDir, publishing, redirects, headers);
|
|
2029
|
+
}
|
|
2030
|
+
async function writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders = []) {
|
|
2031
|
+
const headers = [
|
|
2032
|
+
...manifest.artifacts
|
|
2033
|
+
.map((artifact) => {
|
|
2034
|
+
const artifactHeaders = artifactPublishingHeaders(artifact);
|
|
2035
|
+
return Object.keys(artifactHeaders).length > 0
|
|
2036
|
+
? { path: artifact.path, headers: artifactHeaders }
|
|
2037
|
+
: undefined;
|
|
2038
|
+
})
|
|
2039
|
+
.filter((entry) => entry !== undefined),
|
|
2040
|
+
...staticAssetHeaders,
|
|
2041
|
+
];
|
|
2042
|
+
const outputPath = join(outDir, "headers.json");
|
|
2043
|
+
if (headers.length === 0) {
|
|
2044
|
+
await rm(outputPath, { force: true });
|
|
2045
|
+
return headers;
|
|
2046
|
+
}
|
|
2047
|
+
await writeJson(outputPath, { headers });
|
|
2048
|
+
return headers;
|
|
2049
|
+
}
|
|
2050
|
+
function normalizePublishingRedirects(redirects, trailingSlash) {
|
|
2051
|
+
return redirects.map((redirect) => ({
|
|
2052
|
+
from: normalizeRedirectPath(redirect.from, trailingSlash),
|
|
2053
|
+
to: normalizeRedirectPath(redirect.to, trailingSlash),
|
|
2054
|
+
status: redirect.status ?? 301,
|
|
2055
|
+
}));
|
|
2056
|
+
}
|
|
2057
|
+
async function writeNativePublishingArtifacts(outDir, publishing, redirects, headers) {
|
|
2058
|
+
if (publishing.nativeRedirects === undefined || publishing.nativeRedirects === false) {
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (publishing.nativeRedirects !== "netlify") {
|
|
2062
|
+
throw new Error(`Unsupported native redirects format: ${publishing.nativeRedirects}`);
|
|
2063
|
+
}
|
|
2064
|
+
if (redirects.length > 0) {
|
|
2065
|
+
await writeText(join(outDir, "_redirects"), renderNetlifyRedirects(redirects));
|
|
2066
|
+
}
|
|
2067
|
+
else {
|
|
2068
|
+
await rm(join(outDir, "_redirects"), { force: true });
|
|
2069
|
+
}
|
|
2070
|
+
if (publishing.headers !== false && headers.length > 0) {
|
|
2071
|
+
await writeText(join(outDir, "_headers"), renderNetlifyHeaders(headers));
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
await rm(join(outDir, "_headers"), { force: true });
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
function artifactPublishingHeaders(artifact) {
|
|
2078
|
+
return {
|
|
2079
|
+
...(artifact.contentType ? { "content-type": artifact.contentType } : {}),
|
|
2080
|
+
...(artifact.headers ?? {}),
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function renderRobotsTxt(robots, baseUrl, trailingSlash) {
|
|
2084
|
+
const lines = [];
|
|
2085
|
+
for (const rule of robots.rules ?? []) {
|
|
2086
|
+
for (const userAgent of Array.isArray(rule.userAgent) ? rule.userAgent : [rule.userAgent]) {
|
|
2087
|
+
lines.push(`User-agent: ${userAgent}`);
|
|
2088
|
+
}
|
|
2089
|
+
for (const allow of rule.allow ?? []) {
|
|
2090
|
+
lines.push(`Allow: ${allow}`);
|
|
2091
|
+
}
|
|
2092
|
+
for (const disallow of rule.disallow ?? []) {
|
|
2093
|
+
lines.push(`Disallow: ${disallow}`);
|
|
2094
|
+
}
|
|
2095
|
+
if (rule.crawlDelay !== undefined) {
|
|
2096
|
+
lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
const sitemapUrls = robots.sitemap === false
|
|
2100
|
+
? []
|
|
2101
|
+
: Array.isArray(robots.sitemap)
|
|
2102
|
+
? robots.sitemap
|
|
2103
|
+
: [`${baseUrl.replace(/\/$/, "")}/sitemap.xml`];
|
|
2104
|
+
for (const sitemap of sitemapUrls) {
|
|
2105
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
2106
|
+
}
|
|
2107
|
+
return `${lines.join("\n")}\n`;
|
|
2108
|
+
}
|
|
2109
|
+
function normalizeRedirectPath(value, trailingSlash) {
|
|
2110
|
+
return value.startsWith("/") && !value.startsWith("//") ? normalizePath(value, trailingSlash) : value;
|
|
2111
|
+
}
|
|
2112
|
+
function isInternalRedirectPath(value) {
|
|
2113
|
+
return value.startsWith("/") && !value.startsWith("//");
|
|
2114
|
+
}
|
|
2115
|
+
// Reserved artifact `route` used to mark generated redirect HTML stubs in the
|
|
2116
|
+
// manifest. It is rejected as a user artifact/route family name (see
|
|
2117
|
+
// assertReservedRouteUnused) so prior redirect stubs can be identified by route
|
|
2118
|
+
// without clashing with a real user family.
|
|
2119
|
+
const REDIRECT_ARTIFACT_ROUTE = "redirect";
|
|
2120
|
+
const reservedGeneratedArtifactRoutes = new Set([
|
|
2121
|
+
REDIRECT_ARTIFACT_ROUTE,
|
|
2122
|
+
fragmentClientRouteName,
|
|
2123
|
+
]);
|
|
2124
|
+
// Reject a user artifact family whose name would collide with the reserved
|
|
2125
|
+
// redirect-stub route. Without this, a retained user artifact named "redirect"
|
|
2126
|
+
// could not be distinguished from a generated redirect stub during partial-merge
|
|
2127
|
+
// collision handling.
|
|
2128
|
+
function assertReservedRouteUnused(kind, name) {
|
|
2129
|
+
if (reservedGeneratedArtifactRoutes.has(name)) {
|
|
2130
|
+
throw new Error(`${kind} family name "${name}" is reserved for generated StoneAge artifacts; choose a different name.`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
// Register a planned redirect HTML stub as a manifest artifact so it rides the
|
|
2134
|
+
// existing orphan-detection, stale-cleanup, and partial-merge machinery. The
|
|
2135
|
+
// stored `path` is `/<outputPath>` because the compacted manifest does not
|
|
2136
|
+
// persist `outputPath`; the validator reconstructs it from `path` by stripping
|
|
2137
|
+
// the leading slash (see outputPathForArtifactPath in cli.ts). `contentType` is
|
|
2138
|
+
// intentionally omitted so redirect stubs behave like generated HTML pages and
|
|
2139
|
+
// do not emit spurious entries in headers.json.
|
|
2140
|
+
function redirectArtifact(file) {
|
|
2141
|
+
return {
|
|
2142
|
+
route: REDIRECT_ARTIFACT_ROUTE,
|
|
2143
|
+
path: `/${file.outputPath}`,
|
|
2144
|
+
outputPath: file.outputPath,
|
|
2145
|
+
dependencies: {},
|
|
2146
|
+
bytes: Buffer.byteLength(file.html),
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
// Output paths a redirect HTML stub *could* occupy, derived purely from the
|
|
2150
|
+
// publishing config using the same internal-`from` derivation as
|
|
2151
|
+
// planRedirectHtmlFiles. Used (together with the reserved route) to recognise a
|
|
2152
|
+
// redirect's own previously-registered stub when re-planning on a partial
|
|
2153
|
+
// rebuild, so it is not treated as an occupied path.
|
|
2154
|
+
function redirectOutputPathSet(redirects, trailingSlash) {
|
|
2155
|
+
const outputPaths = new Set();
|
|
2156
|
+
for (const redirect of redirects ?? []) {
|
|
2157
|
+
if (!isInternalRedirectPath(redirect.from)) {
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
outputPaths.add(outputPathFor(normalizeRedirectPath(redirect.from, trailingSlash), trailingSlash));
|
|
2161
|
+
}
|
|
2162
|
+
return outputPaths;
|
|
2163
|
+
}
|
|
2164
|
+
function planRedirectHtmlFiles(redirects, baseUrl, trailingSlash, occupied) {
|
|
2165
|
+
const taken = new Set(occupied);
|
|
2166
|
+
const planned = [];
|
|
2167
|
+
for (const redirect of redirects) {
|
|
2168
|
+
if (!isInternalRedirectPath(redirect.from)) {
|
|
2169
|
+
// `from` points outside the site (absolute URL or protocol-relative);
|
|
2170
|
+
// a static page cannot be written for it, so leave it to the manifest.
|
|
2171
|
+
console.warn(`StoneAge: skipping redirect HTML for non-internal source "${redirect.from}".`);
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
const from = normalizeRedirectPath(redirect.from, trailingSlash);
|
|
2175
|
+
const outputPath = outputPathFor(from, trailingSlash);
|
|
2176
|
+
if (taken.has(outputPath)) {
|
|
2177
|
+
// A real page, artifact, or public asset already owns this path; never
|
|
2178
|
+
// overwrite it.
|
|
2179
|
+
console.warn(`StoneAge: skipping redirect HTML for "${from}" because a page or asset already exists at that path.`);
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
taken.add(outputPath);
|
|
2183
|
+
const target = normalizeRedirectPath(redirect.to, trailingSlash);
|
|
2184
|
+
const canonical = isInternalRedirectPath(redirect.to)
|
|
2185
|
+
? absoluteUrl(baseUrl, redirect.to, trailingSlash)
|
|
2186
|
+
: redirect.to;
|
|
2187
|
+
planned.push({ outputPath, html: renderRedirectHtml(target, canonical) });
|
|
2188
|
+
}
|
|
2189
|
+
return planned;
|
|
2190
|
+
}
|
|
2191
|
+
function renderRedirectHtml(target, canonical) {
|
|
2192
|
+
const href = escapeAttribute(target);
|
|
2193
|
+
return [
|
|
2194
|
+
"<!doctype html>",
|
|
2195
|
+
'<html lang="en">',
|
|
2196
|
+
"<head>",
|
|
2197
|
+
'<meta charset="utf-8">',
|
|
2198
|
+
`<meta http-equiv="refresh" content="0; url=${href}">`,
|
|
2199
|
+
`<link rel="canonical" href="${escapeAttribute(canonical)}">`,
|
|
2200
|
+
'<meta name="robots" content="noindex">',
|
|
2201
|
+
"<title>Redirecting…</title>",
|
|
2202
|
+
"</head>",
|
|
2203
|
+
"<body>",
|
|
2204
|
+
`<p>Redirecting to <a href="${href}">${escapeHtml(target)}</a></p>`,
|
|
2205
|
+
"</body>",
|
|
2206
|
+
"</html>",
|
|
2207
|
+
"",
|
|
2208
|
+
].join("\n");
|
|
2209
|
+
}
|
|
2210
|
+
async function writeSitemaps(outDir, manifest, baseUrl, trailingSlash = "always", sitemap) {
|
|
2211
|
+
const pageUrls = sitemapPageUrls(manifest, baseUrl, trailingSlash);
|
|
2212
|
+
const maxUrlsPerFile = readSitemapMaxUrlsPerFile(sitemap);
|
|
2213
|
+
if (maxUrlsPerFile === undefined || pageUrls.length <= maxUrlsPerFile) {
|
|
2214
|
+
await writeText(join(outDir, "sitemap.xml"), renderSitemapUrlSet(pageUrls));
|
|
2215
|
+
await removeStaleSplitSitemaps(outDir, []);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const chunks = chunkArray(pageUrls, maxUrlsPerFile);
|
|
2219
|
+
const files = chunks.map((_, index) => `sitemap-${index + 1}.xml`);
|
|
2220
|
+
await Promise.all(chunks.map((urls, index) => writeText(join(outDir, files[index]), renderSitemapUrlSet(urls))));
|
|
2221
|
+
await writeText(join(outDir, "sitemap.xml"), renderSitemapIndex(files.map((file) => absoluteArtifactUrl(baseUrl, `/${file}`))));
|
|
2222
|
+
await removeStaleSplitSitemaps(outDir, files);
|
|
2223
|
+
}
|
|
2224
|
+
function sitemapFilesFor(manifest, sitemap) {
|
|
2225
|
+
const urlCount = manifest.pages.filter((page) => page.sitemap).length;
|
|
2226
|
+
const maxUrlsPerFile = readSitemapMaxUrlsPerFile(sitemap);
|
|
2227
|
+
if (maxUrlsPerFile === undefined || urlCount <= maxUrlsPerFile) {
|
|
2228
|
+
return ["sitemap.xml"];
|
|
2229
|
+
}
|
|
2230
|
+
return [
|
|
2231
|
+
"sitemap.xml",
|
|
2232
|
+
...Array.from({ length: Math.ceil(urlCount / maxUrlsPerFile) }, (_, index) => `sitemap-${index + 1}.xml`),
|
|
2233
|
+
];
|
|
2234
|
+
}
|
|
2235
|
+
function readSitemapMaxUrlsPerFile(sitemap) {
|
|
2236
|
+
const maxUrlsPerFile = sitemap?.maxUrlsPerFile;
|
|
2237
|
+
if (maxUrlsPerFile === undefined) {
|
|
2238
|
+
return undefined;
|
|
2239
|
+
}
|
|
2240
|
+
if (!Number.isInteger(maxUrlsPerFile) || maxUrlsPerFile < 1) {
|
|
2241
|
+
throw new Error("sitemap.maxUrlsPerFile must be a positive integer.");
|
|
2242
|
+
}
|
|
2243
|
+
return maxUrlsPerFile;
|
|
2244
|
+
}
|
|
2245
|
+
function sitemapPageUrls(manifest, baseUrl, trailingSlash) {
|
|
2246
|
+
return manifest.pages
|
|
2247
|
+
.filter((page) => page.sitemap)
|
|
2248
|
+
.map((page) => absoluteUrl(baseUrl, page.path, trailingSlash));
|
|
2249
|
+
}
|
|
2250
|
+
function renderSitemapUrlSet(urls) {
|
|
2251
|
+
const entries = urls.map((url) => `<url><loc>${escapeHtml(url)}</loc></url>`).join("");
|
|
2252
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2253
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</urlset>
|
|
2254
|
+
`;
|
|
2255
|
+
}
|
|
2256
|
+
function renderSitemapIndex(urls) {
|
|
2257
|
+
const entries = urls
|
|
2258
|
+
.map((url) => `<sitemap><loc>${escapeHtml(url)}</loc></sitemap>`)
|
|
2259
|
+
.join("");
|
|
2260
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2261
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</sitemapindex>
|
|
2262
|
+
`;
|
|
2263
|
+
}
|
|
2264
|
+
function createReport(manifest, pagesWritten, artifactsWritten, publicAssetsCopied, publicAssetsSkipped, publicAssets, staleOutputsRemoved, sitemapRendered, sitemapFiles, routeWrites, artifactWrites, validationPages, validation, buildFingerprint, renderFingerprint, fragmentDefaultFallbackText, fragmentClientPublicPath) {
|
|
2265
|
+
const existingPaths = new Set([
|
|
2266
|
+
...manifest.pages.map((page) => page.path),
|
|
2267
|
+
...manifest.artifacts.map((artifact) => artifact.path),
|
|
2268
|
+
]);
|
|
2269
|
+
const routeFamilies = {};
|
|
2270
|
+
const artifactFamilies = {};
|
|
2271
|
+
const missingLinks = [];
|
|
2272
|
+
for (const page of manifest.pages) {
|
|
2273
|
+
if (page.route !== notFoundRouteName) {
|
|
2274
|
+
routeFamilies[page.route] = (routeFamilies[page.route] ?? 0) + 1;
|
|
2275
|
+
}
|
|
2276
|
+
for (const link of page.links) {
|
|
2277
|
+
if (!existingPaths.has(link)) {
|
|
2278
|
+
missingLinks.push(`${page.path} -> ${link}`);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
for (const artifact of manifest.artifacts) {
|
|
2283
|
+
artifactFamilies[artifact.route] = (artifactFamilies[artifact.route] ?? 0) + 1;
|
|
2284
|
+
}
|
|
2285
|
+
return {
|
|
2286
|
+
...(buildFingerprint === undefined ? {} : { buildFingerprint }),
|
|
2287
|
+
...(renderFingerprint === undefined ? {} : { renderFingerprint }),
|
|
2288
|
+
...(fragmentDefaultFallbackText === undefined ? {} : { fragmentDefaultFallbackText }),
|
|
2289
|
+
...(fragmentClientPublicPath === undefined ? {} : { fragmentClientPublicPath }),
|
|
2290
|
+
pageCount: manifest.pages.length,
|
|
2291
|
+
pagesWritten,
|
|
2292
|
+
artifactCount: manifest.artifacts.length,
|
|
2293
|
+
artifactsWritten,
|
|
2294
|
+
publicAssetsCopied,
|
|
2295
|
+
publicAssetsSkipped,
|
|
2296
|
+
publicAssets,
|
|
2297
|
+
staleOutputsRemoved,
|
|
2298
|
+
sitemapRendered,
|
|
2299
|
+
sitemapFiles,
|
|
2300
|
+
routeFamilies,
|
|
2301
|
+
routeWrites,
|
|
2302
|
+
artifactFamilies,
|
|
2303
|
+
artifactWrites,
|
|
2304
|
+
missingLinks,
|
|
2305
|
+
largestHtml: largestOutputs(manifest.pages, (page) => page.htmlBytes),
|
|
2306
|
+
largestArtifacts: largestOutputs(manifest.artifacts, (artifact) => artifact.bytes),
|
|
2307
|
+
validation: createValidationReport(manifest, validationPages, validation),
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
function largestOutputs(outputs, bytesOf, limit = 20) {
|
|
2311
|
+
const largest = [];
|
|
2312
|
+
for (const output of outputs) {
|
|
2313
|
+
const bytes = bytesOf(output);
|
|
2314
|
+
let insertAt = largest.length;
|
|
2315
|
+
while (insertAt > 0 && bytes > largest[insertAt - 1].bytes) {
|
|
2316
|
+
insertAt -= 1;
|
|
2317
|
+
}
|
|
2318
|
+
if (insertAt >= limit) {
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
largest.splice(insertAt, 0, { output, bytes });
|
|
2322
|
+
if (largest.length > limit) {
|
|
2323
|
+
largest.length = limit;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return largest.map(({ output, bytes }) => ({ path: output.path, bytes }));
|
|
2327
|
+
}
|
|
2328
|
+
function createDataFlowReport(manifest, routeWrites, artifactWrites) {
|
|
2329
|
+
return {
|
|
2330
|
+
...createDataFlowSummaryReport(manifest, routeWrites, artifactWrites),
|
|
2331
|
+
dependencies: dependencySummaries(manifest),
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
function createDataFlowSummaryReport(manifest, routeWrites, artifactWrites) {
|
|
2335
|
+
return {
|
|
2336
|
+
version: 1,
|
|
2337
|
+
outputs: {
|
|
2338
|
+
pages: manifest.pages.length,
|
|
2339
|
+
artifacts: manifest.artifacts.length,
|
|
2340
|
+
total: manifest.pages.length + manifest.artifacts.length,
|
|
2341
|
+
},
|
|
2342
|
+
routeFamilies: familySummaries(manifest.pages.filter((page) => page.route !== notFoundRouteName), (page) => page.route, (page) => page.htmlBytes, routeWrites),
|
|
2343
|
+
artifactFamilies: familySummaries(manifest.artifacts, (artifact) => artifact.route, (artifact) => artifact.bytes, artifactWrites),
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
function createDataFlowDependenciesReport(manifest) {
|
|
2347
|
+
return {
|
|
2348
|
+
version: 1,
|
|
2349
|
+
dependencies: dependencySummaries(manifest),
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
function canReusePreviousDataFlowDependencies(previousPages, previousArtifacts, pageResults, artifactResults) {
|
|
2353
|
+
for (const result of pageResults) {
|
|
2354
|
+
if (result.pagesWritten === 0) {
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
const previousPage = previousPages.get(result.page.path);
|
|
2358
|
+
if (previousPage === undefined ||
|
|
2359
|
+
previousPage.route !== result.page.route ||
|
|
2360
|
+
!samePublicDependencyKeys(previousPage.dependencies, result.page.dependencies)) {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
for (const result of artifactResults) {
|
|
2365
|
+
if (result.artifactsWritten === 0) {
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
const previousArtifact = previousArtifacts.get(result.artifact.path);
|
|
2369
|
+
if (previousArtifact === undefined ||
|
|
2370
|
+
previousArtifact.route !== result.artifact.route ||
|
|
2371
|
+
!samePublicDependencyKeys(previousArtifact.dependencies, result.artifact.dependencies)) {
|
|
2372
|
+
return false;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return true;
|
|
2376
|
+
}
|
|
2377
|
+
function samePublicDependencyKeys(left, right) {
|
|
2378
|
+
const leftKeys = publicDependencyKeys(left);
|
|
2379
|
+
const rightKeys = publicDependencyKeys(right);
|
|
2380
|
+
if (leftKeys.length !== rightKeys.length) {
|
|
2381
|
+
return false;
|
|
2382
|
+
}
|
|
2383
|
+
const leftKeySet = new Set(leftKeys);
|
|
2384
|
+
return rightKeys.every((key) => leftKeySet.has(key));
|
|
2385
|
+
}
|
|
2386
|
+
function familySummaries(outputs, routeOf, bytesOf, writes) {
|
|
2387
|
+
const summaries = new Map();
|
|
2388
|
+
for (const output of outputs) {
|
|
2389
|
+
const name = routeOf(output);
|
|
2390
|
+
const summary = summaries.get(name) ??
|
|
2391
|
+
{
|
|
2392
|
+
name,
|
|
2393
|
+
outputs: 0,
|
|
2394
|
+
written: writes[name] ?? 0,
|
|
2395
|
+
skipped: 0,
|
|
2396
|
+
bytes: 0,
|
|
2397
|
+
};
|
|
2398
|
+
summary.outputs += 1;
|
|
2399
|
+
summary.bytes += bytesOf(output);
|
|
2400
|
+
summaries.set(name, summary);
|
|
2401
|
+
}
|
|
2402
|
+
return [...summaries.values()]
|
|
2403
|
+
.map((summary) => ({
|
|
2404
|
+
...summary,
|
|
2405
|
+
skipped: Math.max(0, summary.outputs - summary.written),
|
|
2406
|
+
}))
|
|
2407
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
2408
|
+
}
|
|
2409
|
+
function dependencySummaries(manifest) {
|
|
2410
|
+
const summaries = new Map();
|
|
2411
|
+
for (const page of manifest.pages) {
|
|
2412
|
+
for (const key of publicDependencyKeys(page.dependencies)) {
|
|
2413
|
+
const summary = dependencySummaryFor(summaries, key);
|
|
2414
|
+
summary.outputs += 1;
|
|
2415
|
+
summary.pages += 1;
|
|
2416
|
+
summary.routes[page.route] = (summary.routes[page.route] ?? 0) + 1;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
for (const artifact of manifest.artifacts) {
|
|
2420
|
+
for (const key of publicDependencyKeys(artifact.dependencies)) {
|
|
2421
|
+
const summary = dependencySummaryFor(summaries, key);
|
|
2422
|
+
summary.outputs += 1;
|
|
2423
|
+
summary.artifacts += 1;
|
|
2424
|
+
summary.artifactRoutes[artifact.route] =
|
|
2425
|
+
(summary.artifactRoutes[artifact.route] ?? 0) + 1;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
return [...summaries.values()].sort((left, right) => right.outputs - left.outputs || left.key.localeCompare(right.key));
|
|
2429
|
+
}
|
|
2430
|
+
function publicDependencyKeys(dependencies) {
|
|
2431
|
+
const keys = [];
|
|
2432
|
+
for (const key in dependencies) {
|
|
2433
|
+
if (Object.prototype.hasOwnProperty.call(dependencies, key) &&
|
|
2434
|
+
!key.startsWith("\0stoneage:")) {
|
|
2435
|
+
keys.push(key);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
return keys;
|
|
2439
|
+
}
|
|
2440
|
+
function dependencySummaryFor(summaries, key) {
|
|
2441
|
+
const existing = summaries.get(key);
|
|
2442
|
+
if (existing) {
|
|
2443
|
+
return existing;
|
|
2444
|
+
}
|
|
2445
|
+
const summary = {
|
|
2446
|
+
key,
|
|
2447
|
+
outputs: 0,
|
|
2448
|
+
pages: 0,
|
|
2449
|
+
artifacts: 0,
|
|
2450
|
+
routes: {},
|
|
2451
|
+
artifactRoutes: {},
|
|
2452
|
+
};
|
|
2453
|
+
summaries.set(key, summary);
|
|
2454
|
+
return summary;
|
|
2455
|
+
}
|
|
2456
|
+
function validationPageFor(path, canonical, metadata, site) {
|
|
2457
|
+
return {
|
|
2458
|
+
path,
|
|
2459
|
+
title: metadata.title,
|
|
2460
|
+
description: metadata.description,
|
|
2461
|
+
canonical,
|
|
2462
|
+
favicon: site.favicon ?? "",
|
|
2463
|
+
ogImage: metadata.ogImage ?? site.ogImage ?? "",
|
|
2464
|
+
twitterImage: metadata.twitter?.image ?? site.twitter?.image ?? "",
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
function createValidationReport(manifest, validationPages, validation) {
|
|
2468
|
+
const issues = [];
|
|
2469
|
+
const requiredMetadata = validation?.requiredMetadata ?? ["title", "description", "canonical"];
|
|
2470
|
+
for (const page of validationPages) {
|
|
2471
|
+
if (requiredMetadata.includes("title") && page.title.trim() === "") {
|
|
2472
|
+
issues.push({
|
|
2473
|
+
code: "metadata.missing_title",
|
|
2474
|
+
path: page.path,
|
|
2475
|
+
message: `${page.path} is missing a title.`,
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
if (requiredMetadata.includes("description") && page.description.trim() === "") {
|
|
2479
|
+
issues.push({
|
|
2480
|
+
code: "metadata.missing_description",
|
|
2481
|
+
path: page.path,
|
|
2482
|
+
message: `${page.path} is missing a description.`,
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
if (requiredMetadata.includes("canonical") && page.canonical.trim() === "") {
|
|
2486
|
+
issues.push({
|
|
2487
|
+
code: "metadata.missing_canonical",
|
|
2488
|
+
path: page.path,
|
|
2489
|
+
message: `${page.path} is missing a canonical URL.`,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
if (requiredMetadata.includes("favicon") && page.favicon.trim() === "") {
|
|
2493
|
+
issues.push({
|
|
2494
|
+
code: "metadata.missing_favicon",
|
|
2495
|
+
path: page.path,
|
|
2496
|
+
message: `${page.path} is missing a favicon.`,
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
if (requiredMetadata.includes("ogImage") && page.ogImage.trim() === "") {
|
|
2500
|
+
issues.push({
|
|
2501
|
+
code: "metadata.missing_og_image",
|
|
2502
|
+
path: page.path,
|
|
2503
|
+
message: `${page.path} is missing an Open Graph image.`,
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
if (requiredMetadata.includes("twitterImage") && page.twitterImage.trim() === "") {
|
|
2507
|
+
issues.push({
|
|
2508
|
+
code: "metadata.missing_twitter_image",
|
|
2509
|
+
path: page.path,
|
|
2510
|
+
message: `${page.path} is missing a Twitter image.`,
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const canonicalPaths = new Map();
|
|
2515
|
+
for (const page of validationPages) {
|
|
2516
|
+
const canonical = page.canonical.trim();
|
|
2517
|
+
if (canonical === "") {
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
canonicalPaths.set(canonical, [...(canonicalPaths.get(canonical) ?? []), page.path]);
|
|
2521
|
+
}
|
|
2522
|
+
for (const [canonical, paths] of canonicalPaths) {
|
|
2523
|
+
if (paths.length < 2) {
|
|
2524
|
+
continue;
|
|
2525
|
+
}
|
|
2526
|
+
for (const path of paths) {
|
|
2527
|
+
issues.push({
|
|
2528
|
+
code: "canonical.duplicate",
|
|
2529
|
+
path,
|
|
2530
|
+
message: `${path} shares canonical URL ${canonical} with ${paths.length - 1} other page(s).`,
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
const maxHtmlBytes = validation?.budgets?.maxHtmlBytes;
|
|
2535
|
+
if (maxHtmlBytes !== undefined) {
|
|
2536
|
+
for (const page of manifest.pages) {
|
|
2537
|
+
if (page.htmlBytes > maxHtmlBytes) {
|
|
2538
|
+
issues.push({
|
|
2539
|
+
code: "budget.html",
|
|
2540
|
+
path: page.path,
|
|
2541
|
+
bytes: page.htmlBytes,
|
|
2542
|
+
limit: maxHtmlBytes,
|
|
2543
|
+
message: `${page.path} is ${page.htmlBytes} bytes, exceeding the ${maxHtmlBytes} byte HTML budget.`,
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
// Count-based page budgets ignore the synthetic not-found page so it does not
|
|
2549
|
+
// push a site over its budget or appear as a phantom route family.
|
|
2550
|
+
const budgetPages = manifest.pages.filter((page) => page.route !== notFoundRouteName);
|
|
2551
|
+
const maxPages = validation?.budgets?.maxPages;
|
|
2552
|
+
if (maxPages !== undefined && budgetPages.length > maxPages) {
|
|
2553
|
+
issues.push({
|
|
2554
|
+
code: "budget.pages",
|
|
2555
|
+
path: ".",
|
|
2556
|
+
bytes: budgetPages.length,
|
|
2557
|
+
limit: maxPages,
|
|
2558
|
+
message: `Generated page count is ${budgetPages.length}, exceeding the ${maxPages} page budget.`,
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
const maxArtifacts = validation?.budgets?.maxArtifacts;
|
|
2562
|
+
if (maxArtifacts !== undefined && manifest.artifacts.length > maxArtifacts) {
|
|
2563
|
+
issues.push({
|
|
2564
|
+
code: "budget.artifacts",
|
|
2565
|
+
path: ".",
|
|
2566
|
+
bytes: manifest.artifacts.length,
|
|
2567
|
+
limit: maxArtifacts,
|
|
2568
|
+
message: `Generated artifact count is ${manifest.artifacts.length}, exceeding the ${maxArtifacts} artifact budget.`,
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
for (const issue of familyCountBudgetIssues(pageFamilyCounts(budgetPages), validation?.budgets?.maxRouteFamilyPages, "budget.route_family_pages", "pages", "page route family")) {
|
|
2572
|
+
issues.push(issue);
|
|
2573
|
+
}
|
|
2574
|
+
for (const issue of familyCountBudgetIssues(artifactFamilyCounts(manifest.artifacts), validation?.budgets?.maxArtifactFamilyOutputs, "budget.artifact_family_outputs", "artifacts", "artifact family output")) {
|
|
2575
|
+
issues.push(issue);
|
|
2576
|
+
}
|
|
2577
|
+
const maxDependencyFanout = validation?.budgets?.maxDependencyFanout;
|
|
2578
|
+
if (maxDependencyFanout !== undefined) {
|
|
2579
|
+
for (const dependency of dependencySummaries(manifest)) {
|
|
2580
|
+
if (dependency.outputs > maxDependencyFanout) {
|
|
2581
|
+
issues.push({
|
|
2582
|
+
code: "budget.dependency_fanout",
|
|
2583
|
+
dependency: dependency.key,
|
|
2584
|
+
outputs: dependency.outputs,
|
|
2585
|
+
limit: maxDependencyFanout,
|
|
2586
|
+
message: `${dependency.key} fans out to ${dependency.outputs} outputs, exceeding the ${maxDependencyFanout} output dependency fanout budget.`,
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const validationConfigHash = validation?.configHash ?? (validation ? hashValue(validation) : undefined);
|
|
2592
|
+
return {
|
|
2593
|
+
ok: issues.length === 0,
|
|
2594
|
+
...(validation?.configPath ? { configPath: validation.configPath } : {}),
|
|
2595
|
+
...(validationConfigHash ? { configHash: validationConfigHash } : {}),
|
|
2596
|
+
issues,
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
function pageFamilyCounts(pages) {
|
|
2600
|
+
return familyCounts(pages, (page) => page.route);
|
|
2601
|
+
}
|
|
2602
|
+
function artifactFamilyCounts(artifacts) {
|
|
2603
|
+
return familyCounts(artifacts, (artifact) => artifact.route);
|
|
2604
|
+
}
|
|
2605
|
+
function familyCounts(outputs, familyOf) {
|
|
2606
|
+
const counts = {};
|
|
2607
|
+
for (const output of outputs) {
|
|
2608
|
+
const family = familyOf(output);
|
|
2609
|
+
counts[family] = (counts[family] ?? 0) + 1;
|
|
2610
|
+
}
|
|
2611
|
+
return counts;
|
|
2612
|
+
}
|
|
2613
|
+
function familyCountBudgetIssues(counts, limits, code, outputLabel, budgetLabel) {
|
|
2614
|
+
if (!limits) {
|
|
2615
|
+
return [];
|
|
2616
|
+
}
|
|
2617
|
+
return Object.entries(limits)
|
|
2618
|
+
.flatMap(([family, limit]) => {
|
|
2619
|
+
const count = counts[family] ?? 0;
|
|
2620
|
+
return count > limit
|
|
2621
|
+
? [
|
|
2622
|
+
{
|
|
2623
|
+
code,
|
|
2624
|
+
path: family,
|
|
2625
|
+
bytes: count,
|
|
2626
|
+
limit,
|
|
2627
|
+
message: `${family} generated ${count} ${outputLabel}, exceeding the ${limit} ${budgetLabel} budget.`,
|
|
2628
|
+
},
|
|
2629
|
+
]
|
|
2630
|
+
: [];
|
|
2631
|
+
})
|
|
2632
|
+
.sort((left, right) => (right.bytes ?? 0) - (left.bytes ?? 0) ||
|
|
2633
|
+
(left.path ?? "").localeCompare(right.path ?? ""));
|
|
2634
|
+
}
|
|
2635
|
+
function sameSitemapEntries(left, right) {
|
|
2636
|
+
if (left.length !== right.length) {
|
|
2637
|
+
return false;
|
|
2638
|
+
}
|
|
2639
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
2640
|
+
const leftPage = left[index];
|
|
2641
|
+
const rightPage = right[index];
|
|
2642
|
+
if (leftPage.path !== rightPage.path || leftPage.sitemap !== rightPage.sitemap) {
|
|
2643
|
+
return false;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
return true;
|
|
2647
|
+
}
|
|
2648
|
+
function reusesManifestEntries(previous, current) {
|
|
2649
|
+
return (reusesArrayEntries(previous.pages, current.pages) &&
|
|
2650
|
+
reusesArrayEntries(previous.artifacts, current.artifacts));
|
|
2651
|
+
}
|
|
2652
|
+
function reusesArrayEntries(previous, current) {
|
|
2653
|
+
if (previous.length !== current.length) {
|
|
2654
|
+
return false;
|
|
2655
|
+
}
|
|
2656
|
+
for (let index = 0; index < previous.length; index += 1) {
|
|
2657
|
+
if (previous[index] !== current[index]) {
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
return true;
|
|
2662
|
+
}
|
|
2663
|
+
function unchangedBuildReportFromPrevious(previous, changes) {
|
|
2664
|
+
return {
|
|
2665
|
+
...previous,
|
|
2666
|
+
...changes,
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
function sameStringList(left, right) {
|
|
2670
|
+
if (left.length !== right.length) {
|
|
2671
|
+
return false;
|
|
2672
|
+
}
|
|
2673
|
+
return left.every((value, index) => value === right[index]);
|
|
2674
|
+
}
|
|
2675
|
+
function extractInternalLinks(html, trailingSlash = "always") {
|
|
2676
|
+
const links = new Set();
|
|
2677
|
+
const linkPattern = /<a\b[^>]*\bhref="([^"]+)"/g;
|
|
2678
|
+
let match;
|
|
2679
|
+
while ((match = linkPattern.exec(html)) !== null) {
|
|
2680
|
+
const href = match[1];
|
|
2681
|
+
if (!href.startsWith("/") || href.startsWith("//")) {
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
links.add(normalizeInternalHref(href, trailingSlash));
|
|
2685
|
+
}
|
|
2686
|
+
return [...links].sort();
|
|
2687
|
+
}
|
|
2688
|
+
function normalizeInternalHref(href, trailingSlash) {
|
|
2689
|
+
return hasPathExtension(href) ? normalizeArtifactPath(href) : normalizePath(href, trailingSlash);
|
|
2690
|
+
}
|
|
2691
|
+
function hasPathExtension(path) {
|
|
2692
|
+
const [pathname] = path.split(/[?#]/, 1);
|
|
2693
|
+
const lastSegment = pathname.split("/").pop() ?? "";
|
|
2694
|
+
return /\.[A-Za-z0-9]+$/.test(lastSegment);
|
|
2695
|
+
}
|
|
2696
|
+
function outputPathFor(path, trailingSlash = "always") {
|
|
2697
|
+
const normalized = normalizePath(path, trailingSlash);
|
|
2698
|
+
if (normalized === "/") {
|
|
2699
|
+
return "index.html";
|
|
2700
|
+
}
|
|
2701
|
+
if (trailingSlash === "never") {
|
|
2702
|
+
return `${normalized.slice(1)}.html`;
|
|
2703
|
+
}
|
|
2704
|
+
return join(normalized.slice(1), "index.html");
|
|
2705
|
+
}
|
|
2706
|
+
function outputPathForArtifact(path) {
|
|
2707
|
+
return normalizeArtifactPath(path).slice(1);
|
|
2708
|
+
}
|
|
2709
|
+
function normalizePath(path, trailingSlash = "always") {
|
|
2710
|
+
const [pathname] = path.split(/[?#]/, 1);
|
|
2711
|
+
if (pathname === "/") {
|
|
2712
|
+
return pathname;
|
|
2713
|
+
}
|
|
2714
|
+
const withoutTrailingSlash = pathname.replace(/\/+$/, "");
|
|
2715
|
+
return trailingSlash === "never" ? withoutTrailingSlash : `${withoutTrailingSlash}/`;
|
|
2716
|
+
}
|
|
2717
|
+
function normalizeArtifactPath(path) {
|
|
2718
|
+
const [pathname] = path.split(/[?#]/, 1);
|
|
2719
|
+
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
2720
|
+
if (normalized === "/") {
|
|
2721
|
+
throw new Error("Artifact path cannot be the site root.");
|
|
2722
|
+
}
|
|
2723
|
+
if (normalized.endsWith("/")) {
|
|
2724
|
+
throw new Error("Artifact path cannot end with a slash.");
|
|
2725
|
+
}
|
|
2726
|
+
return normalized.replaceAll(/\/+/g, "/");
|
|
2727
|
+
}
|
|
2728
|
+
function normalizeSvgAssetDir(dir) {
|
|
2729
|
+
const [pathname] = dir.split(/[?#]/, 1);
|
|
2730
|
+
if (!pathname.startsWith("/")) {
|
|
2731
|
+
throw new Error(`SVG asset dir must be an absolute site path. Received ${JSON.stringify(dir)}.`);
|
|
2732
|
+
}
|
|
2733
|
+
const normalized = pathname.replaceAll(/\/+/g, "/").replace(/\/+$/, "");
|
|
2734
|
+
if (normalized === "") {
|
|
2735
|
+
throw new Error("SVG asset dir cannot be the site root.");
|
|
2736
|
+
}
|
|
2737
|
+
return normalized;
|
|
2738
|
+
}
|
|
2739
|
+
function normalizeSvgAssetContent(svg, familyName) {
|
|
2740
|
+
const normalized = svg.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\n$/, "");
|
|
2741
|
+
if (normalized.trim() === "" || !normalized.trimStart().startsWith("<svg")) {
|
|
2742
|
+
throw new Error(`SVG asset family ${familyName} rendered non-SVG content.`);
|
|
2743
|
+
}
|
|
2744
|
+
return normalized;
|
|
2745
|
+
}
|
|
2746
|
+
function svgAssetContentHash(svg) {
|
|
2747
|
+
return createHash("sha256").update(svg).digest("hex").slice(0, 16);
|
|
2748
|
+
}
|
|
2749
|
+
function normalizeFragmentCandidatePath(path) {
|
|
2750
|
+
const [pathname] = path.split(/[?#]/, 1);
|
|
2751
|
+
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
2752
|
+
const collapsed = normalized.replaceAll(/\/+/g, "/").replace(/\/+$/, "");
|
|
2753
|
+
if (collapsed === "" || collapsed === "/") {
|
|
2754
|
+
throw new Error("Fragment path cannot be the site root.");
|
|
2755
|
+
}
|
|
2756
|
+
if (collapsed === "/_fragments" || collapsed.startsWith("/_fragments/")) {
|
|
2757
|
+
return collapsed;
|
|
2758
|
+
}
|
|
2759
|
+
return `/_fragments${collapsed}`;
|
|
2760
|
+
}
|
|
2761
|
+
function absoluteArtifactUrl(baseUrl, path) {
|
|
2762
|
+
return `${baseUrl.replace(/\/$/, "")}${normalizeArtifactPath(path)}`;
|
|
2763
|
+
}
|
|
2764
|
+
function absoluteUrl(baseUrl, path, trailingSlash = "always") {
|
|
2765
|
+
return `${baseUrl.replace(/\/$/, "")}${normalizePath(path, trailingSlash)}`;
|
|
2766
|
+
}
|
|
2767
|
+
function chunkArray(items, size) {
|
|
2768
|
+
const chunks = [];
|
|
2769
|
+
for (let index = 0; index < items.length; index += size) {
|
|
2770
|
+
chunks.push(items.slice(index, index + size));
|
|
2771
|
+
}
|
|
2772
|
+
return chunks;
|
|
2773
|
+
}
|
|
2774
|
+
async function writeText(path, contents, options = {}) {
|
|
2775
|
+
if (options.compareExisting !== false) {
|
|
2776
|
+
try {
|
|
2777
|
+
const current = await readFile(path, "utf8");
|
|
2778
|
+
if (current === contents) {
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
catch (error) {
|
|
2783
|
+
if (!isMissingPath(error)) {
|
|
2784
|
+
throw error;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2789
|
+
await writeFile(path, contents);
|
|
2790
|
+
}
|
|
2791
|
+
async function isMissingFile(path) {
|
|
2792
|
+
try {
|
|
2793
|
+
await readFile(path);
|
|
2794
|
+
return false;
|
|
2795
|
+
}
|
|
2796
|
+
catch (error) {
|
|
2797
|
+
if (isMissingPath(error)) {
|
|
2798
|
+
return true;
|
|
2799
|
+
}
|
|
2800
|
+
throw error;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
async function writeJson(path, value) {
|
|
2804
|
+
await writeText(path, `${JSON.stringify(value)}\n`);
|
|
2805
|
+
}
|
|
2806
|
+
async function writeBytes(path, contents, options = {}) {
|
|
2807
|
+
if (options.compareExisting !== false) {
|
|
2808
|
+
try {
|
|
2809
|
+
const current = await readFile(path);
|
|
2810
|
+
if (Buffer.compare(current, contents) === 0) {
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
catch (error) {
|
|
2815
|
+
if (!isMissingPath(error)) {
|
|
2816
|
+
throw error;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2821
|
+
await writeFile(path, contents);
|
|
2822
|
+
}
|
|
2823
|
+
function artifactContent(content) {
|
|
2824
|
+
if (!isArtifactResponse(content)) {
|
|
2825
|
+
return { body: content, metadata: {} };
|
|
2826
|
+
}
|
|
2827
|
+
return {
|
|
2828
|
+
body: content.body,
|
|
2829
|
+
metadata: artifactMetadata(content),
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
function artifactMetadata(response) {
|
|
2833
|
+
return {
|
|
2834
|
+
...(response.contentType ? { contentType: response.contentType } : {}),
|
|
2835
|
+
...(response.status !== undefined ? { status: response.status } : {}),
|
|
2836
|
+
...(response.headers ? { headers: response.headers } : {}),
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
function isArtifactResponse(content) {
|
|
2840
|
+
return (typeof content === "object" &&
|
|
2841
|
+
content !== null &&
|
|
2842
|
+
!Buffer.isBuffer(content) &&
|
|
2843
|
+
!(content instanceof Uint8Array) &&
|
|
2844
|
+
"body" in content);
|
|
2845
|
+
}
|
|
2846
|
+
function normalizeArtifactStatus(status) {
|
|
2847
|
+
if (status === undefined) {
|
|
2848
|
+
return 200;
|
|
2849
|
+
}
|
|
2850
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
2851
|
+
throw new Error(`Artifact response status must be an integer from 100 to 599. Received ${status}.`);
|
|
2852
|
+
}
|
|
2853
|
+
return status;
|
|
2854
|
+
}
|
|
2855
|
+
function normalizeArtifactHeaders(headers = {}) {
|
|
2856
|
+
return Object.fromEntries(Object.entries(headers)
|
|
2857
|
+
.filter(([name]) => name.trim() !== "")
|
|
2858
|
+
.map(([name, value]) => [name.toLowerCase(), String(value)])
|
|
2859
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
2860
|
+
}
|
|
2861
|
+
function toBuffer(content) {
|
|
2862
|
+
return Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
2863
|
+
}
|
|
2864
|
+
async function removeStaleSplitSitemaps(outDir, keepFiles) {
|
|
2865
|
+
const keep = new Set(keepFiles);
|
|
2866
|
+
let entries;
|
|
2867
|
+
try {
|
|
2868
|
+
entries = await readdir(outDir);
|
|
2869
|
+
}
|
|
2870
|
+
catch (error) {
|
|
2871
|
+
if (isMissingPath(error)) {
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
throw error;
|
|
2875
|
+
}
|
|
2876
|
+
await Promise.all(entries
|
|
2877
|
+
.filter((entry) => /^sitemap-\d+\.xml$/.test(entry) && !keep.has(entry))
|
|
2878
|
+
.map((entry) => rm(join(outDir, entry), { force: true })));
|
|
2879
|
+
}
|
|
2880
|
+
function hashText(value) {
|
|
2881
|
+
return createHash("sha256").update(value).digest().subarray(0, 16).toString("base64url");
|
|
2882
|
+
}
|
|
2883
|
+
function escapeHtml(value) {
|
|
2884
|
+
return value
|
|
2885
|
+
.replaceAll("&", "&")
|
|
2886
|
+
.replaceAll("<", "<")
|
|
2887
|
+
.replaceAll(">", ">");
|
|
2888
|
+
}
|
|
2889
|
+
function escapeAttribute(value) {
|
|
2890
|
+
return escapeHtml(value).replaceAll('"', """);
|
|
2891
|
+
}
|
|
2892
|
+
function escapeJavaScriptString(value) {
|
|
2893
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
2894
|
+
}
|
|
2895
|
+
function isMissingPath(error) {
|
|
2896
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2897
|
+
}
|