snappy-prerender 2.6.0-rc.3 → 2.6.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 +140 -0
- package/README.md +42 -21
- package/package.json +8 -5
- package/src/cli.js +95 -9
- package/src/core/artifacts.js +110 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +128 -2
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/init.js +44 -1
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +52 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +64 -10
- package/src/core/resources.js +1 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +177 -304
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +49 -17
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +14 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
package/src/core/routes.js
CHANGED
|
@@ -43,6 +43,15 @@ export function matchesPattern(route, pattern) {
|
|
|
43
43
|
function matchesAny(route, patterns) {
|
|
44
44
|
return patterns.some((pattern) => matchesPattern(route, pattern));
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reports whether a route belongs in the output. An empty pattern list allows every
|
|
49
|
+
* route; a supplied list is an allowlist for output as well as for discovery, so a
|
|
50
|
+
* seed used only to reach wildcard routes is not written itself.
|
|
51
|
+
*/
|
|
52
|
+
export function isIncludedRoute(route, patterns = []) {
|
|
53
|
+
return patterns.length === 0 || matchesAny(route, patterns);
|
|
54
|
+
}
|
|
46
55
|
/**
|
|
47
56
|
* Reports whether a route is safe to map onto the output directory. Literal dot
|
|
48
57
|
* segments are already resolved, so this catches the encoded forms: percent-encoded
|
package/src/core/run.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { writePreloadManifest, writeSitemap } from './artifacts.js';
|
|
3
4
|
import { launchBrowser } from './browser.js';
|
|
4
5
|
import { resolveConfig } from './config.js';
|
|
5
6
|
import { prepareDestination } from './destination.js';
|
|
6
|
-
import { inlineCriticalCss } from './inline-css.js';
|
|
7
7
|
import { createLogger } from './log.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
findOutputCollisions,
|
|
12
|
-
routeToScreenshotFile,
|
|
13
|
-
writeFileIfChanged,
|
|
14
|
-
writeRouteHtml,
|
|
15
|
-
} from './output.js';
|
|
16
|
-
import { buildPreloadManifest } from './preload-manifest.js';
|
|
8
|
+
import { routeToFile } from './output.js';
|
|
9
|
+
import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
|
|
17
10
|
import { renderRoute } from './render.js';
|
|
18
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
crawlRoots,
|
|
13
|
+
filterRoutes,
|
|
14
|
+
isIncludedRoute,
|
|
15
|
+
normaliseRoute,
|
|
16
|
+
withinDepth,
|
|
17
|
+
} from './routes.js';
|
|
19
18
|
import { createPool } from './scheduler.js';
|
|
20
19
|
import { startCommandServer, startStaticServer } from './server.js';
|
|
21
|
-
import { buildSitemap, withSitemapDirective } from './sitemap.js';
|
|
22
20
|
import { verifyRoutes } from './verify.js';
|
|
21
|
+
import { writeAllRoutes } from './write.js';
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Entry point of the prerenderer: serves the built output, crawls and renders
|
|
26
25
|
* routes in a real browser, post-processes and writes one file per route, then
|
|
27
|
-
* verifies hydration.
|
|
28
|
-
*
|
|
26
|
+
* verifies hydration. Route, page, verification and artifact failures are returned
|
|
27
|
+
* in the report; setup errors such as invalid config, a missing source directory or
|
|
28
|
+
* a browser that cannot start are thrown so the caller can stop immediately.
|
|
29
29
|
*/
|
|
30
30
|
export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
31
31
|
const config = resolveConfig(userOptions);
|
|
@@ -36,6 +36,8 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
36
36
|
|
|
37
37
|
await assertSourceDir(sourceDir, config);
|
|
38
38
|
const outputDir = await resolveOutputDir({ sourceDir, config, log });
|
|
39
|
+
const previousManifest =
|
|
40
|
+
config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
|
|
39
41
|
|
|
40
42
|
if (config.saveAs !== 'html' && config.verify) {
|
|
41
43
|
log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
|
|
@@ -55,10 +57,15 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
55
57
|
files: [],
|
|
56
58
|
errors: [],
|
|
57
59
|
pageErrors: [],
|
|
60
|
+
consoleErrors: [],
|
|
61
|
+
failedRequests: [],
|
|
62
|
+
emptyPages: [],
|
|
58
63
|
duplicateTitles: [],
|
|
64
|
+
duplicatePages: [],
|
|
59
65
|
verification: null,
|
|
60
66
|
preloadManifest: null,
|
|
61
67
|
sitemap: null,
|
|
68
|
+
pruned: [],
|
|
62
69
|
truncated: null,
|
|
63
70
|
ok: false,
|
|
64
71
|
durationMs: 0,
|
|
@@ -78,9 +85,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
78
85
|
config,
|
|
79
86
|
log,
|
|
80
87
|
report,
|
|
81
|
-
manifestEntries,
|
|
82
|
-
outputDir,
|
|
83
88
|
});
|
|
89
|
+
if (report.routes.length === 0) {
|
|
90
|
+
report.errors.push({ route: null, message: 'no routes were rendered' });
|
|
91
|
+
log.error('No routes were rendered; check include, exclude and sourceDir');
|
|
92
|
+
}
|
|
84
93
|
const pageErrors = report.pageErrors.length;
|
|
85
94
|
log.info(
|
|
86
95
|
`Rendered ${report.routes.length} route(s)${pageErrors > 0 ? `, ${pageErrors} page error(s)` : ''}`,
|
|
@@ -91,13 +100,26 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
91
100
|
);
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
const verifiable = await writeAllRoutes({
|
|
103
|
+
const verifiable = await writeAllRoutes({
|
|
104
|
+
rendered,
|
|
105
|
+
outputDir,
|
|
106
|
+
config,
|
|
107
|
+
log,
|
|
108
|
+
report,
|
|
109
|
+
state,
|
|
110
|
+
manifestEntries,
|
|
111
|
+
});
|
|
95
112
|
logFileSummary(report, log);
|
|
96
113
|
logNotFoundNotice(report, config, log);
|
|
97
114
|
logDuplicateTitles(report, log);
|
|
98
115
|
|
|
99
116
|
if (config.sitemap) {
|
|
100
|
-
|
|
117
|
+
try {
|
|
118
|
+
await writeSitemap({ outputDir, config, log, report });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
report.errors.push({ route: null, message: `sitemap write failed: ${error.message}` });
|
|
121
|
+
log.error(`sitemap write failed: ${error.message}`);
|
|
122
|
+
}
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
if (report.truncated) {
|
|
@@ -106,13 +128,22 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
106
128
|
);
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
if (config.preloadManifest
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
if (config.preloadManifest) {
|
|
132
|
+
try {
|
|
133
|
+
report.preloadManifest = await writePreloadManifest({
|
|
134
|
+
manifestEntries,
|
|
135
|
+
outputDir,
|
|
136
|
+
config,
|
|
137
|
+
log,
|
|
138
|
+
report,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
report.errors.push({
|
|
142
|
+
route: null,
|
|
143
|
+
message: `preload manifest write failed: ${error.message}`,
|
|
144
|
+
});
|
|
145
|
+
log.error(`preload manifest write failed: ${error.message}`);
|
|
146
|
+
}
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
|
|
@@ -121,12 +152,16 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
121
152
|
'hydration verification is skipped with serveCmd: the command server decides which document it serves, so the written files cannot be checked',
|
|
122
153
|
);
|
|
123
154
|
}
|
|
124
|
-
// With verifyChangedOnly, a route whose file was left untouched is not reloaded: the HTML
|
|
155
|
+
// With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
|
|
125
156
|
// and the bundle are the same as the last time it was verified.
|
|
126
157
|
const toVerify = config.verifyChangedOnly
|
|
127
|
-
? verifiable.filter(
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
? verifiable.filter((route) => {
|
|
159
|
+
const file = routeToFile(route, config);
|
|
160
|
+
const entry = report.files.find(
|
|
161
|
+
(candidate) => candidate.route === route && candidate.file === file,
|
|
162
|
+
);
|
|
163
|
+
return entry?.status !== 'unchanged';
|
|
164
|
+
})
|
|
130
165
|
: verifiable;
|
|
131
166
|
const skipped = verifiable.length - toVerify.length;
|
|
132
167
|
if (canVerify && !config.serveCmd && skipped > 0) {
|
|
@@ -142,6 +177,31 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
142
177
|
report.verification.skipped = skipped;
|
|
143
178
|
logVerification(report.verification, config, log);
|
|
144
179
|
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
config.pruneStaleRoutes &&
|
|
183
|
+
!config.dryRun &&
|
|
184
|
+
report.errors.length === 0 &&
|
|
185
|
+
report.truncated === null
|
|
186
|
+
) {
|
|
187
|
+
try {
|
|
188
|
+
report.pruned = await pruneStaleFiles({
|
|
189
|
+
dir: outputDir,
|
|
190
|
+
previous: previousManifest,
|
|
191
|
+
current: report.files.map((file) => file.file).filter(Boolean),
|
|
192
|
+
log,
|
|
193
|
+
});
|
|
194
|
+
await writePruneManifest({
|
|
195
|
+
dir: outputDir,
|
|
196
|
+
files: report.files.map((file) => file.file).filter(Boolean),
|
|
197
|
+
});
|
|
198
|
+
if (report.pruned.length > 0) {
|
|
199
|
+
log.info(`Pruned ${report.pruned.length} stale file(s)`);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
log.warn(`could not prune stale output: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
145
205
|
} finally {
|
|
146
206
|
if (browser) await browser.close();
|
|
147
207
|
const stopped = await server.close();
|
|
@@ -152,10 +212,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
152
212
|
|
|
153
213
|
report.durationMs = Date.now() - startedAt;
|
|
154
214
|
const reRendered = report.verification?.modes['re-rendered'] ?? 0;
|
|
215
|
+
const undetected = report.verification
|
|
216
|
+
? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
|
|
217
|
+
: 0;
|
|
155
218
|
report.ok =
|
|
156
219
|
!(config.failOnError && (report.errors.length > 0 || report.truncated !== null)) &&
|
|
157
220
|
!(config.failOnPageError && report.pageErrors.length > 0) &&
|
|
221
|
+
!(config.failOnEmptyPage && report.emptyPages.length > 0) &&
|
|
222
|
+
!(config.failOnConsoleError && report.consoleErrors.length > 0) &&
|
|
223
|
+
!(config.failOnFailedRequest && report.failedRequests.length > 0) &&
|
|
224
|
+
!(config.failOnDuplicateContent && report.duplicatePages.length > 0) &&
|
|
158
225
|
!(config.failOnHydrationError && report.verification && !report.verification.ok) &&
|
|
226
|
+
!(config.failOnVerificationError && report.verification?.errors.length > 0) &&
|
|
227
|
+
!(config.failOnUndetected && undetected > 0) &&
|
|
159
228
|
!(config.failOnRerender && reRendered > 0);
|
|
160
229
|
return report;
|
|
161
230
|
}
|
|
@@ -170,6 +239,19 @@ async function assertSourceDir(sourceDir, config) {
|
|
|
170
239
|
if (!stats?.isDirectory()) {
|
|
171
240
|
throw new Error(`sourceDir does not exist or is not a directory: ${sourceDir}`);
|
|
172
241
|
}
|
|
242
|
+
const hasIndex = await Promise.all(
|
|
243
|
+
['index.html', 'index.htm'].map((name) =>
|
|
244
|
+
fs
|
|
245
|
+
.stat(path.join(sourceDir, name))
|
|
246
|
+
.then((entry) => entry.isFile())
|
|
247
|
+
.catch(() => false),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
if (!hasIndex.some(Boolean)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`sourceDir has no index.html: ${sourceDir}. Point sourceDir at the built app, not its assets folder.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
173
255
|
}
|
|
174
256
|
|
|
175
257
|
/**
|
|
@@ -183,6 +265,7 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
183
265
|
sourceDir,
|
|
184
266
|
destination: config.destination ? path.resolve(config.destination) : null,
|
|
185
267
|
log,
|
|
268
|
+
cleanDestination: config.cleanDestination,
|
|
186
269
|
});
|
|
187
270
|
}
|
|
188
271
|
|
|
@@ -190,22 +273,16 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
190
273
|
* Runs the crawl and render pool. Each rendered page contributes its output, any new
|
|
191
274
|
* same-origin routes, and the resources recorded for the link-hint features.
|
|
192
275
|
*/
|
|
193
|
-
async function renderAllRoutes({
|
|
194
|
-
browser,
|
|
195
|
-
server,
|
|
196
|
-
config,
|
|
197
|
-
log,
|
|
198
|
-
report,
|
|
199
|
-
manifestEntries,
|
|
200
|
-
outputDir,
|
|
201
|
-
}) {
|
|
276
|
+
async function renderAllRoutes({ browser, server, config, log, report }) {
|
|
202
277
|
const visited = new Set();
|
|
203
278
|
const scheduled = new Set();
|
|
204
279
|
const rendered = new Map();
|
|
205
280
|
const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
|
|
206
281
|
|
|
207
282
|
// Queues routes up to maxRoutes, flagging the report the first time the cap bites.
|
|
208
|
-
|
|
283
|
+
// A seed used only to reach wildcard routes is given output: false and is rendered
|
|
284
|
+
// for its links without being written.
|
|
285
|
+
const accept = (routes, outputFor = () => true) => {
|
|
209
286
|
const accepted = [];
|
|
210
287
|
for (const route of routes) {
|
|
211
288
|
if (scheduled.has(route)) continue;
|
|
@@ -214,48 +291,69 @@ async function renderAllRoutes({
|
|
|
214
291
|
break;
|
|
215
292
|
}
|
|
216
293
|
scheduled.add(route);
|
|
217
|
-
accepted.push(route);
|
|
294
|
+
accepted.push({ route, output: outputFor(route) });
|
|
218
295
|
}
|
|
219
296
|
return accepted;
|
|
220
297
|
};
|
|
221
298
|
|
|
222
299
|
const pool = createPool({
|
|
223
300
|
concurrency: config.concurrency,
|
|
224
|
-
handler: async ({ route, depth }) => {
|
|
301
|
+
handler: async ({ route, depth, output }) => {
|
|
225
302
|
if (visited.has(route)) return;
|
|
226
303
|
visited.add(route);
|
|
227
|
-
log.debug(`rendering ${route}`);
|
|
304
|
+
log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
|
|
228
305
|
try {
|
|
229
|
-
const screenshotPath =
|
|
230
|
-
config.saveAs !== 'html' && !config.dryRun
|
|
231
|
-
? path.join(outputDir, routeToScreenshotFile(route, config))
|
|
232
|
-
: null;
|
|
233
306
|
const result = await renderRoute({
|
|
234
307
|
browser,
|
|
235
308
|
origin: server.origin,
|
|
236
309
|
route,
|
|
237
310
|
config,
|
|
238
|
-
|
|
239
|
-
});
|
|
240
|
-
rendered.set(route, result);
|
|
241
|
-
report.routes.push(route);
|
|
242
|
-
manifestEntries.push({
|
|
243
|
-
route,
|
|
244
|
-
scripts: [...result.collector.scripts],
|
|
245
|
-
styles: [...result.collector.styles],
|
|
311
|
+
screenshot: config.saveAs !== 'html',
|
|
246
312
|
});
|
|
313
|
+
if (output && result.empty) {
|
|
314
|
+
report.emptyPages.push(route);
|
|
315
|
+
if (config.failOnEmptyPage) {
|
|
316
|
+
report.errors.push({ route, message: 'rendered an empty page' });
|
|
317
|
+
log.error(` empty page ${route}: no app content was found`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
log.warn(` empty page ${route}: no app content was found`);
|
|
321
|
+
}
|
|
322
|
+
if (output) {
|
|
323
|
+
rendered.set(route, result);
|
|
324
|
+
report.routes.push(route);
|
|
325
|
+
}
|
|
247
326
|
for (const message of result.pageErrors) {
|
|
248
327
|
report.pageErrors.push({ route, message });
|
|
249
328
|
log.warn(` page error on ${route}: ${message}`);
|
|
250
329
|
}
|
|
330
|
+
for (const message of result.consoleErrors) {
|
|
331
|
+
report.consoleErrors.push({ route, message });
|
|
332
|
+
}
|
|
333
|
+
if (result.consoleErrors.length > 0) {
|
|
334
|
+
log.debug(` console error on ${route}: ${result.consoleErrors[0]}`);
|
|
335
|
+
if (config.failOnConsoleError) {
|
|
336
|
+
report.errors.push({ route, message: `console error: ${result.consoleErrors[0]}` });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const failure of result.failedRequests) {
|
|
340
|
+
report.failedRequests.push({ route, ...failure });
|
|
341
|
+
const detail = failure.status ? `HTTP ${failure.status}` : failure.error;
|
|
342
|
+
log.warn(` failed request on ${route}: ${failure.url} (${detail})`);
|
|
343
|
+
}
|
|
344
|
+
if (result.failedRequests.length > 0 && config.failOnFailedRequest) {
|
|
345
|
+
const first = result.failedRequests[0];
|
|
346
|
+
report.errors.push({
|
|
347
|
+
route,
|
|
348
|
+
message: `failed request: ${first.url}${first.status ? ` (HTTP ${first.status})` : ''}`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
251
351
|
if (config.crawl && withinDepth(depth + 1, config.maxDepth)) {
|
|
252
352
|
const discovered = filterRoutes(result.links, {
|
|
253
353
|
include: config.includeProvided ? config.include : [],
|
|
254
354
|
exclude: config.exclude,
|
|
255
355
|
}).filter((candidate) => !scheduled.has(candidate));
|
|
256
|
-
pool.push(
|
|
257
|
-
accept(discovered).map((candidate) => ({ route: candidate, depth: depth + 1 })),
|
|
258
|
-
);
|
|
356
|
+
pool.push(accept(discovered).map((item) => ({ ...item, depth: depth + 1 })));
|
|
259
357
|
}
|
|
260
358
|
} catch (error) {
|
|
261
359
|
report.errors.push({ route, message: error.message });
|
|
@@ -264,8 +362,11 @@ async function renderAllRoutes({
|
|
|
264
362
|
},
|
|
265
363
|
});
|
|
266
364
|
|
|
267
|
-
const roots = accept(
|
|
268
|
-
|
|
365
|
+
const roots = accept(
|
|
366
|
+
crawlRoots(config),
|
|
367
|
+
(route) => !config.includeProvided || isIncludedRoute(route, config.include),
|
|
368
|
+
);
|
|
369
|
+
pool.push(roots.map((item) => ({ ...item, depth: 0 })));
|
|
269
370
|
const { errors } = await pool.run();
|
|
270
371
|
for (const { item, error } of errors) {
|
|
271
372
|
report.errors.push({ route: item.route, message: error.message });
|
|
@@ -274,179 +375,6 @@ async function renderAllRoutes({
|
|
|
274
375
|
return rendered;
|
|
275
376
|
}
|
|
276
377
|
|
|
277
|
-
/**
|
|
278
|
-
* Writes every rendered route and returns the routes that can be verified. A write
|
|
279
|
-
* failure is recorded against its route instead of aborting the remaining writes.
|
|
280
|
-
*/
|
|
281
|
-
async function writeAllRoutes({ rendered, outputDir, config, log, report, state }) {
|
|
282
|
-
const sorted = [...report.routes].sort();
|
|
283
|
-
const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
|
|
284
|
-
|
|
285
|
-
await writeStateScripts({ rendered, outputDir, config, log, report });
|
|
286
|
-
|
|
287
|
-
const verifiable = [];
|
|
288
|
-
for (const route of sorted) {
|
|
289
|
-
if (conflicted.has(route)) continue;
|
|
290
|
-
try {
|
|
291
|
-
const file = await writeRouteOutput({
|
|
292
|
-
route,
|
|
293
|
-
result: rendered.get(route),
|
|
294
|
-
outputDir,
|
|
295
|
-
config,
|
|
296
|
-
log,
|
|
297
|
-
state,
|
|
298
|
-
report,
|
|
299
|
-
});
|
|
300
|
-
report.files.push(file);
|
|
301
|
-
verifiable.push(route);
|
|
302
|
-
} catch (error) {
|
|
303
|
-
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
304
|
-
log.error(` write failed for ${route}: ${error.message}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
return verifiable;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Writes the captured-state scripts routes reference when externalScripts is on. The payload
|
|
312
|
-
* is content-addressed, so routes that inject the same state share one file and a rerun
|
|
313
|
-
* leaves it untouched.
|
|
314
|
-
*/
|
|
315
|
-
async function writeStateScripts({ rendered, outputDir, config, log, report }) {
|
|
316
|
-
const seen = new Set();
|
|
317
|
-
for (const [route, result] of rendered) {
|
|
318
|
-
const stateScript = result?.stateScript;
|
|
319
|
-
if (!stateScript || seen.has(stateScript.file)) continue;
|
|
320
|
-
seen.add(stateScript.file);
|
|
321
|
-
if (config.dryRun) {
|
|
322
|
-
report.files.push({
|
|
323
|
-
route,
|
|
324
|
-
file: stateScript.file,
|
|
325
|
-
status: 'dry-run',
|
|
326
|
-
bytes: Buffer.byteLength(stateScript.contents),
|
|
327
|
-
});
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
try {
|
|
331
|
-
const written = await writeFileIfChanged({
|
|
332
|
-
dir: outputDir,
|
|
333
|
-
file: stateScript.file,
|
|
334
|
-
contents: stateScript.contents,
|
|
335
|
-
});
|
|
336
|
-
report.files.push({ route, ...written });
|
|
337
|
-
log.debug(` wrote ${stateScript.file}: ${written.status}`);
|
|
338
|
-
} catch (error) {
|
|
339
|
-
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
340
|
-
log.error(` write failed for ${stateScript.file}: ${error.message}`);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Records an error for every route involved in an output file collision and returns
|
|
347
|
-
* the routes to skip, so an ambiguous write can never overwrite another route.
|
|
348
|
-
*/
|
|
349
|
-
function reportOutputCollisions({ routes, config, log, report }) {
|
|
350
|
-
const conflicted = new Set();
|
|
351
|
-
for (const [file, colliding] of findOutputCollisions(routes, config)) {
|
|
352
|
-
for (const route of colliding) {
|
|
353
|
-
conflicted.add(route);
|
|
354
|
-
const others = colliding.filter((other) => other !== route).join(', ');
|
|
355
|
-
report.errors.push({
|
|
356
|
-
route,
|
|
357
|
-
message: `output file collision: ${file} is also written by ${others}`,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
log.error(` output file collision on ${file}: ${colliding.join(', ')}`);
|
|
361
|
-
}
|
|
362
|
-
return conflicted;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Applies the configured post-processing to one rendered route and writes it: DOM
|
|
367
|
-
* cleanups and link hints, critical CSS extraction, then optional minification.
|
|
368
|
-
*/
|
|
369
|
-
async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
|
|
370
|
-
if (result.screenshot) {
|
|
371
|
-
const file = routeToScreenshotFile(route, config);
|
|
372
|
-
const stats = await fs.stat(path.join(outputDir, file));
|
|
373
|
-
return { route, file, status: 'written', bytes: stats.size };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const { html, stats } = normaliseHtml(result.html, {
|
|
377
|
-
removeStyleTags: config.removeStyleTags,
|
|
378
|
-
removeScriptTags: config.removeScriptTags,
|
|
379
|
-
asyncScriptTags: config.asyncScriptTags,
|
|
380
|
-
removeBlobs: config.removeBlobs,
|
|
381
|
-
removeAttributes: config.removeAttributes,
|
|
382
|
-
preconnectOrigins: config.preconnectThirdParty
|
|
383
|
-
? [...result.collector.thirdPartyOrigins].sort()
|
|
384
|
-
: [],
|
|
385
|
-
preloadImages: config.preloadImages
|
|
386
|
-
? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
|
|
387
|
-
: [],
|
|
388
|
-
});
|
|
389
|
-
if (
|
|
390
|
-
stats.removedElements > 0 ||
|
|
391
|
-
stats.removedAttributes > 0 ||
|
|
392
|
-
stats.dedupedStyles > 0 ||
|
|
393
|
-
stats.hints > 0 ||
|
|
394
|
-
stats.textSeparators > 0
|
|
395
|
-
) {
|
|
396
|
-
log.debug(
|
|
397
|
-
` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.removedAttributes} attribute(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
if ((result.inlineCssSkipped ?? []).length > 0) {
|
|
401
|
-
log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
let output = html;
|
|
405
|
-
if (config.inlineCss === 'critical') {
|
|
406
|
-
output = await applyCriticalCss({ output, outputDir, config, log, state });
|
|
407
|
-
}
|
|
408
|
-
if (config.minifyHtml) {
|
|
409
|
-
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
410
|
-
}
|
|
411
|
-
warnOnBuildOriginMetadata({ origins: stats.metadataOrigins, route, log, state });
|
|
412
|
-
if (stats.titleElements > 1) report.duplicateTitles.push(route);
|
|
413
|
-
return writeRouteHtml({ dir: outputDir, route, html: output, config });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
|
|
418
|
-
* the output directory, which a command server does not have to serve.
|
|
419
|
-
*/
|
|
420
|
-
async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
421
|
-
if (!config.serveCmd) {
|
|
422
|
-
return inlineCriticalCss({
|
|
423
|
-
html: output,
|
|
424
|
-
outputDir,
|
|
425
|
-
base: config.base,
|
|
426
|
-
preload: config.criticalCssPreload,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
if (!state.warnedCriticalCssSkipped) {
|
|
430
|
-
state.warnedCriticalCssSkipped = true;
|
|
431
|
-
log.warn(
|
|
432
|
-
"inlineCss: 'critical' is skipped with serveCmd: stylesheets are read from the output directory, which the command server does not have to serve",
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
return output;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Warns once when metadata contains the build server's origin, which means the page was
|
|
440
|
-
* composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
|
|
441
|
-
*/
|
|
442
|
-
function warnOnBuildOriginMetadata({ origins, route, log, state }) {
|
|
443
|
-
if (state.warnedBuildOriginMetadata || origins.length === 0) return;
|
|
444
|
-
state.warnedBuildOriginMetadata = true;
|
|
445
|
-
log.warn(
|
|
446
|
-
`metadata on ${route} points at the build server (${origins.join(', ')}); set metadata.siteUrl so og:url, og:image and canonical match the deployed site`,
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
378
|
// Notes routes whose output carries more than one title, which is what React 19 native
|
|
451
379
|
// metadata and the head layer together produce.
|
|
452
380
|
function logDuplicateTitles(report, log) {
|
|
@@ -457,78 +385,6 @@ function logDuplicateTitles(report, log) {
|
|
|
457
385
|
);
|
|
458
386
|
}
|
|
459
387
|
|
|
460
|
-
/**
|
|
461
|
-
* Writes the preload manifest: per route, the scripts and stylesheets it loaded,
|
|
462
|
-
* as Link header values a host can serve for Early Hints or HTTP headers.
|
|
463
|
-
*/
|
|
464
|
-
async function writePreloadManifest({ manifestEntries, outputDir, config, log }) {
|
|
465
|
-
const manifest = buildPreloadManifest(manifestEntries, {
|
|
466
|
-
ignoreForPreload: config.ignoreForPreload,
|
|
467
|
-
base: config.base,
|
|
468
|
-
});
|
|
469
|
-
const file = 'preload-manifest.json';
|
|
470
|
-
await fs.writeFile(path.join(outputDir, file), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
471
|
-
log.info(`Wrote preload manifest covering ${manifest.length} route(s)`);
|
|
472
|
-
return { file, routes: manifest.length };
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Writes sitemap.xml from the routes the run produced, and points an existing robots.txt at
|
|
477
|
-
* it. Every loc must be absolute, which is why the option needs metadata.siteUrl.
|
|
478
|
-
*/
|
|
479
|
-
async function writeSitemap({ outputDir, config, log, report }) {
|
|
480
|
-
if (config.saveAs !== 'html') {
|
|
481
|
-
log.warn(
|
|
482
|
-
`sitemap is skipped when saveAs writes ${config.saveAs} files, as there are no pages to list`,
|
|
483
|
-
);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
const sitemap = buildSitemap({ files: report.files, config });
|
|
487
|
-
if (!sitemap) {
|
|
488
|
-
log.warn('sitemap is skipped: no pages were written');
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const siteUrl = config.metadata.siteUrl.replace(/\/$/, '');
|
|
493
|
-
const sitemapUrl = `${siteUrl}${toPublicPath('/sitemap.xml', config.base)}`;
|
|
494
|
-
const robots = await fs.readFile(path.join(outputDir, 'robots.txt'), 'utf8').catch(() => null);
|
|
495
|
-
const patched = robots === null ? null : withSitemapDirective(robots, sitemapUrl);
|
|
496
|
-
|
|
497
|
-
if (config.dryRun) {
|
|
498
|
-
report.files.push({
|
|
499
|
-
route: null,
|
|
500
|
-
file: 'sitemap.xml',
|
|
501
|
-
status: 'dry-run',
|
|
502
|
-
bytes: Buffer.byteLength(sitemap.contents),
|
|
503
|
-
});
|
|
504
|
-
report.sitemap = { file: 'sitemap.xml', routes: sitemap.routes, robotsUpdated: false };
|
|
505
|
-
log.info(`Dry run: would write sitemap.xml listing ${sitemap.routes} route(s)`);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const written = await writeFileIfChanged({
|
|
510
|
-
dir: outputDir,
|
|
511
|
-
file: 'sitemap.xml',
|
|
512
|
-
contents: sitemap.contents,
|
|
513
|
-
});
|
|
514
|
-
report.files.push({ route: null, ...written });
|
|
515
|
-
report.sitemap = {
|
|
516
|
-
file: 'sitemap.xml',
|
|
517
|
-
routes: sitemap.routes,
|
|
518
|
-
robotsUpdated: patched !== null,
|
|
519
|
-
};
|
|
520
|
-
log.info(`Wrote sitemap.xml listing ${sitemap.routes} route(s)`);
|
|
521
|
-
|
|
522
|
-
if (robots === null) {
|
|
523
|
-
log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
if (patched !== null) {
|
|
527
|
-
await writeFileIfChanged({ dir: outputDir, file: 'robots.txt', contents: patched });
|
|
528
|
-
log.info('Added the sitemap location to robots.txt');
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
388
|
// Notes when a not-found route is configured but was never rendered, so no 404.html
|
|
533
389
|
// was written and a static host would serve its own default page instead.
|
|
534
390
|
function logNotFoundNotice(report, config, log) {
|
|
@@ -560,12 +416,24 @@ function logFileSummary(report, log) {
|
|
|
560
416
|
// markup, surfaces errors the client logged while booting, and notes when hydration failures
|
|
561
417
|
// were reported but not enforced.
|
|
562
418
|
function logVerification(verification, config, log) {
|
|
419
|
+
const modes = verification.modes ?? {};
|
|
420
|
+
log.info(
|
|
421
|
+
`Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
|
|
422
|
+
);
|
|
423
|
+
const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
|
|
424
|
+
if (notDetected > 0) {
|
|
425
|
+
log.warn(
|
|
426
|
+
`${notDetected} route(s) could not be classified for hydration. Check that the app mounts into #root, #app or #__next${config.failOnUndetected ? '' : '; set failOnUndetected to fail the build'}.`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
563
429
|
if (verification.ok) {
|
|
564
430
|
log.success(`Hydration verified for ${verification.routes.length} route(s)`);
|
|
565
431
|
} else {
|
|
566
432
|
const failed = verification.routes.filter((route) => !route.ok);
|
|
567
433
|
for (const entry of failed) {
|
|
568
434
|
log.warn(`Hydration failed on ${entry.route}:`);
|
|
435
|
+
if (entry.status && entry.status >= 400) log.warn(` HTTP ${entry.status}`);
|
|
436
|
+
if (entry.empty) log.warn(' the page rendered no app content');
|
|
569
437
|
for (const message of entry.hydrationErrors) log.warn(` ${message.split('\n')[0]}`);
|
|
570
438
|
}
|
|
571
439
|
for (const error of verification.errors) {
|
|
@@ -576,6 +444,11 @@ function logVerification(verification, config, log) {
|
|
|
576
444
|
`Hydration failures are reported, not enforced, so the prerendered HTML still shipped. Set failOnHydrationError to fail the build instead.`,
|
|
577
445
|
);
|
|
578
446
|
}
|
|
447
|
+
if (verification.errors.length > 0 && !config.failOnVerificationError) {
|
|
448
|
+
log.warn(
|
|
449
|
+
'Verification could not run on some routes; the prerendered HTML still shipped. Set failOnVerificationError to fail the build instead.',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
579
452
|
}
|
|
580
453
|
|
|
581
454
|
for (const entry of verification.routes) {
|