snappy-prerender 2.6.0-rc.3 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -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 +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
package/src/core/run.js
CHANGED
|
@@ -1,31 +1,27 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { writePreloadManifest, writeSitemap } from './artifacts.js';
|
|
4
|
+
import { auditRun } from './audit/index.js';
|
|
3
5
|
import { launchBrowser } from './browser.js';
|
|
4
6
|
import { resolveConfig } from './config.js';
|
|
7
|
+
import { renderAllRoutes } from './crawl.js';
|
|
5
8
|
import { prepareDestination } from './destination.js';
|
|
6
|
-
import { inlineCriticalCss } from './inline-css.js';
|
|
7
9
|
import { createLogger } from './log.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
writeFileIfChanged,
|
|
14
|
-
writeRouteHtml,
|
|
15
|
-
} from './output.js';
|
|
16
|
-
import { buildPreloadManifest } from './preload-manifest.js';
|
|
17
|
-
import { renderRoute } from './render.js';
|
|
18
|
-
import { crawlRoots, filterRoutes, normaliseRoute, toPublicPath, withinDepth } from './routes.js';
|
|
19
|
-
import { createPool } from './scheduler.js';
|
|
10
|
+
import { routeToFile } from './output.js';
|
|
11
|
+
import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
|
|
12
|
+
import { writeReport } from './report.js';
|
|
13
|
+
import { loadRouteSeeds } from './route-sources.js';
|
|
14
|
+
import { normaliseRoute } from './routes.js';
|
|
20
15
|
import { startCommandServer, startStaticServer } from './server.js';
|
|
21
|
-
import { buildSitemap, withSitemapDirective } from './sitemap.js';
|
|
22
16
|
import { verifyRoutes } from './verify.js';
|
|
17
|
+
import { writeAllRoutes } from './write.js';
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
20
|
* Entry point of the prerenderer: serves the built output, crawls and renders
|
|
26
21
|
* routes in a real browser, post-processes and writes one file per route, then
|
|
27
|
-
* verifies hydration.
|
|
28
|
-
*
|
|
22
|
+
* verifies hydration. Route, page, verification and artifact failures are returned
|
|
23
|
+
* in the report; setup errors such as invalid config, a missing source directory or
|
|
24
|
+
* a browser that cannot start are thrown so the caller can stop immediately.
|
|
29
25
|
*/
|
|
30
26
|
export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
31
27
|
const config = resolveConfig(userOptions);
|
|
@@ -36,6 +32,13 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
36
32
|
|
|
37
33
|
await assertSourceDir(sourceDir, config);
|
|
38
34
|
const outputDir = await resolveOutputDir({ sourceDir, config, log });
|
|
35
|
+
const previousManifest =
|
|
36
|
+
config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
|
|
37
|
+
const routeSources = await loadRouteSeeds(config);
|
|
38
|
+
for (const source of routeSources.sources) {
|
|
39
|
+
const skipped = source.skipped > 0 ? `, ${source.skipped} skipped` : '';
|
|
40
|
+
log.info(`Route source ${source.source}: ${source.routes} route(s)${skipped}`);
|
|
41
|
+
}
|
|
39
42
|
|
|
40
43
|
if (config.saveAs !== 'html' && config.verify) {
|
|
41
44
|
log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
|
|
@@ -55,10 +58,22 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
55
58
|
files: [],
|
|
56
59
|
errors: [],
|
|
57
60
|
pageErrors: [],
|
|
61
|
+
consoleErrors: [],
|
|
62
|
+
failedRequests: [],
|
|
63
|
+
emptyPages: [],
|
|
58
64
|
duplicateTitles: [],
|
|
65
|
+
duplicatePages: [],
|
|
66
|
+
links: [],
|
|
67
|
+
notFoundRoutes: [],
|
|
68
|
+
routeSources: routeSources.sources,
|
|
69
|
+
routeDetails: [],
|
|
70
|
+
cspSkipped: [],
|
|
71
|
+
traces: [],
|
|
59
72
|
verification: null,
|
|
73
|
+
audit: null,
|
|
60
74
|
preloadManifest: null,
|
|
61
75
|
sitemap: null,
|
|
76
|
+
pruned: [],
|
|
62
77
|
truncated: null,
|
|
63
78
|
ok: false,
|
|
64
79
|
durationMs: 0,
|
|
@@ -78,9 +93,13 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
78
93
|
config,
|
|
79
94
|
log,
|
|
80
95
|
report,
|
|
81
|
-
manifestEntries,
|
|
82
96
|
outputDir,
|
|
97
|
+
sourceSeeds: routeSources.routes,
|
|
83
98
|
});
|
|
99
|
+
if (report.routes.length === 0) {
|
|
100
|
+
report.errors.push({ route: null, message: 'no routes were rendered' });
|
|
101
|
+
log.error('No routes were rendered; check include, exclude and sourceDir');
|
|
102
|
+
}
|
|
84
103
|
const pageErrors = report.pageErrors.length;
|
|
85
104
|
log.info(
|
|
86
105
|
`Rendered ${report.routes.length} route(s)${pageErrors > 0 ? `, ${pageErrors} page error(s)` : ''}`,
|
|
@@ -91,13 +110,26 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
91
110
|
);
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
const verifiable = await writeAllRoutes({
|
|
113
|
+
const verifiable = await writeAllRoutes({
|
|
114
|
+
rendered,
|
|
115
|
+
outputDir,
|
|
116
|
+
config,
|
|
117
|
+
log,
|
|
118
|
+
report,
|
|
119
|
+
state,
|
|
120
|
+
manifestEntries,
|
|
121
|
+
});
|
|
95
122
|
logFileSummary(report, log);
|
|
96
123
|
logNotFoundNotice(report, config, log);
|
|
97
124
|
logDuplicateTitles(report, log);
|
|
98
125
|
|
|
99
126
|
if (config.sitemap) {
|
|
100
|
-
|
|
127
|
+
try {
|
|
128
|
+
await writeSitemap({ outputDir, config, log, report });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
report.errors.push({ route: null, message: `sitemap write failed: ${error.message}` });
|
|
131
|
+
log.error(`sitemap write failed: ${error.message}`);
|
|
132
|
+
}
|
|
101
133
|
}
|
|
102
134
|
|
|
103
135
|
if (report.truncated) {
|
|
@@ -106,13 +138,22 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
106
138
|
);
|
|
107
139
|
}
|
|
108
140
|
|
|
109
|
-
if (config.preloadManifest
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
if (config.preloadManifest) {
|
|
142
|
+
try {
|
|
143
|
+
report.preloadManifest = await writePreloadManifest({
|
|
144
|
+
manifestEntries,
|
|
145
|
+
outputDir,
|
|
146
|
+
config,
|
|
147
|
+
log,
|
|
148
|
+
report,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
report.errors.push({
|
|
152
|
+
route: null,
|
|
153
|
+
message: `preload manifest write failed: ${error.message}`,
|
|
154
|
+
});
|
|
155
|
+
log.error(`preload manifest write failed: ${error.message}`);
|
|
156
|
+
}
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
|
|
@@ -121,12 +162,16 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
121
162
|
'hydration verification is skipped with serveCmd: the command server decides which document it serves, so the written files cannot be checked',
|
|
122
163
|
);
|
|
123
164
|
}
|
|
124
|
-
// With verifyChangedOnly, a route whose file was left untouched is not reloaded: the HTML
|
|
165
|
+
// With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
|
|
125
166
|
// and the bundle are the same as the last time it was verified.
|
|
126
167
|
const toVerify = config.verifyChangedOnly
|
|
127
|
-
? verifiable.filter(
|
|
128
|
-
|
|
129
|
-
|
|
168
|
+
? verifiable.filter((route) => {
|
|
169
|
+
const file = routeToFile(route, config);
|
|
170
|
+
const entry = report.files.find(
|
|
171
|
+
(candidate) => candidate.route === route && candidate.file === file,
|
|
172
|
+
);
|
|
173
|
+
return entry?.status !== 'unchanged';
|
|
174
|
+
})
|
|
130
175
|
: verifiable;
|
|
131
176
|
const skipped = verifiable.length - toVerify.length;
|
|
132
177
|
if (canVerify && !config.serveCmd && skipped > 0) {
|
|
@@ -138,9 +183,36 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
138
183
|
origin: server.origin,
|
|
139
184
|
routes: toVerify.sort(),
|
|
140
185
|
config,
|
|
186
|
+
outputDir,
|
|
141
187
|
});
|
|
142
188
|
report.verification.skipped = skipped;
|
|
143
189
|
logVerification(report.verification, config, log);
|
|
190
|
+
mergeVerification(report);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
config.pruneStaleRoutes &&
|
|
195
|
+
!config.dryRun &&
|
|
196
|
+
report.errors.length === 0 &&
|
|
197
|
+
report.truncated === null
|
|
198
|
+
) {
|
|
199
|
+
try {
|
|
200
|
+
report.pruned = await pruneStaleFiles({
|
|
201
|
+
dir: outputDir,
|
|
202
|
+
previous: previousManifest,
|
|
203
|
+
current: report.files.map((file) => file.file).filter(Boolean),
|
|
204
|
+
log,
|
|
205
|
+
});
|
|
206
|
+
await writePruneManifest({
|
|
207
|
+
dir: outputDir,
|
|
208
|
+
files: report.files.map((file) => file.file).filter(Boolean),
|
|
209
|
+
});
|
|
210
|
+
if (report.pruned.length > 0) {
|
|
211
|
+
log.info(`Pruned ${report.pruned.length} stale file(s)`);
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
log.warn(`could not prune stale output: ${error.message}`);
|
|
215
|
+
}
|
|
144
216
|
}
|
|
145
217
|
} finally {
|
|
146
218
|
if (browser) await browser.close();
|
|
@@ -151,12 +223,38 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
151
223
|
}
|
|
152
224
|
|
|
153
225
|
report.durationMs = Date.now() - startedAt;
|
|
226
|
+
if (config.audit) {
|
|
227
|
+
try {
|
|
228
|
+
report.audit = await auditRun({ report, config, outputDir });
|
|
229
|
+
logAudit(report.audit, log);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
report.errors.push({ route: null, message: `audit failed: ${error.message}` });
|
|
232
|
+
log.error(`audit failed: ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
154
235
|
const reRendered = report.verification?.modes['re-rendered'] ?? 0;
|
|
236
|
+
const undetected = report.verification
|
|
237
|
+
? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
|
|
238
|
+
: 0;
|
|
155
239
|
report.ok =
|
|
156
240
|
!(config.failOnError && (report.errors.length > 0 || report.truncated !== null)) &&
|
|
157
241
|
!(config.failOnPageError && report.pageErrors.length > 0) &&
|
|
242
|
+
!(config.failOnEmptyPage && report.emptyPages.length > 0) &&
|
|
243
|
+
!(config.failOnConsoleError && report.consoleErrors.length > 0) &&
|
|
244
|
+
!(config.failOnFailedRequest && report.failedRequests.length > 0) &&
|
|
245
|
+
!(config.failOnDuplicateContent && report.duplicatePages.length > 0) &&
|
|
246
|
+
!(config.failOnAuditError && (report.audit?.errors.length ?? 0) > 0) &&
|
|
158
247
|
!(config.failOnHydrationError && report.verification && !report.verification.ok) &&
|
|
248
|
+
!(config.failOnVerificationError && report.verification?.errors.length > 0) &&
|
|
249
|
+
!(config.failOnUndetected && undetected > 0) &&
|
|
159
250
|
!(config.failOnRerender && reRendered > 0);
|
|
251
|
+
try {
|
|
252
|
+
report.reportFile = await writeReport({ report, config, log });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
report.errors.push({ route: null, message: `report write failed: ${error.message}` });
|
|
255
|
+
log.error(`report write failed: ${error.message}`);
|
|
256
|
+
if (config.failOnError) report.ok = false;
|
|
257
|
+
}
|
|
160
258
|
return report;
|
|
161
259
|
}
|
|
162
260
|
|
|
@@ -170,6 +268,19 @@ async function assertSourceDir(sourceDir, config) {
|
|
|
170
268
|
if (!stats?.isDirectory()) {
|
|
171
269
|
throw new Error(`sourceDir does not exist or is not a directory: ${sourceDir}`);
|
|
172
270
|
}
|
|
271
|
+
const hasIndex = await Promise.all(
|
|
272
|
+
['index.html', 'index.htm'].map((name) =>
|
|
273
|
+
fs
|
|
274
|
+
.stat(path.join(sourceDir, name))
|
|
275
|
+
.then((entry) => entry.isFile())
|
|
276
|
+
.catch(() => false),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
if (!hasIndex.some(Boolean)) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`sourceDir has no index.html: ${sourceDir}. Point sourceDir at the built app, not its assets folder.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
173
284
|
}
|
|
174
285
|
|
|
175
286
|
/**
|
|
@@ -183,272 +294,44 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
183
294
|
sourceDir,
|
|
184
295
|
destination: config.destination ? path.resolve(config.destination) : null,
|
|
185
296
|
log,
|
|
297
|
+
cleanDestination: config.cleanDestination,
|
|
186
298
|
});
|
|
187
299
|
}
|
|
188
300
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const visited = new Set();
|
|
203
|
-
const scheduled = new Set();
|
|
204
|
-
const rendered = new Map();
|
|
205
|
-
const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
|
|
206
|
-
|
|
207
|
-
// Queues routes up to maxRoutes, flagging the report the first time the cap bites.
|
|
208
|
-
const accept = (routes) => {
|
|
209
|
-
const accepted = [];
|
|
210
|
-
for (const route of routes) {
|
|
211
|
-
if (scheduled.has(route)) continue;
|
|
212
|
-
if (scheduled.size >= cap) {
|
|
213
|
-
report.truncated = { limit: config.maxRoutes, routes: scheduled.size };
|
|
214
|
-
break;
|
|
215
|
-
}
|
|
216
|
-
scheduled.add(route);
|
|
217
|
-
accepted.push(route);
|
|
218
|
-
}
|
|
219
|
-
return accepted;
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const pool = createPool({
|
|
223
|
-
concurrency: config.concurrency,
|
|
224
|
-
handler: async ({ route, depth }) => {
|
|
225
|
-
if (visited.has(route)) return;
|
|
226
|
-
visited.add(route);
|
|
227
|
-
log.debug(`rendering ${route}`);
|
|
228
|
-
try {
|
|
229
|
-
const screenshotPath =
|
|
230
|
-
config.saveAs !== 'html' && !config.dryRun
|
|
231
|
-
? path.join(outputDir, routeToScreenshotFile(route, config))
|
|
232
|
-
: null;
|
|
233
|
-
const result = await renderRoute({
|
|
234
|
-
browser,
|
|
235
|
-
origin: server.origin,
|
|
236
|
-
route,
|
|
237
|
-
config,
|
|
238
|
-
screenshotPath,
|
|
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],
|
|
246
|
-
});
|
|
247
|
-
for (const message of result.pageErrors) {
|
|
248
|
-
report.pageErrors.push({ route, message });
|
|
249
|
-
log.warn(` page error on ${route}: ${message}`);
|
|
250
|
-
}
|
|
251
|
-
if (config.crawl && withinDepth(depth + 1, config.maxDepth)) {
|
|
252
|
-
const discovered = filterRoutes(result.links, {
|
|
253
|
-
include: config.includeProvided ? config.include : [],
|
|
254
|
-
exclude: config.exclude,
|
|
255
|
-
}).filter((candidate) => !scheduled.has(candidate));
|
|
256
|
-
pool.push(
|
|
257
|
-
accept(discovered).map((candidate) => ({ route: candidate, depth: depth + 1 })),
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
} catch (error) {
|
|
261
|
-
report.errors.push({ route, message: error.message });
|
|
262
|
-
log.error(` failed ${route}: ${error.message}`);
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
const roots = accept(crawlRoots(config));
|
|
268
|
-
pool.push(roots.map((route) => ({ route, depth: 0 })));
|
|
269
|
-
const { errors } = await pool.run();
|
|
270
|
-
for (const { item, error } of errors) {
|
|
271
|
-
report.errors.push({ route: item.route, message: error.message });
|
|
272
|
-
log.error(` pool error on ${item.route}: ${error.message}`);
|
|
273
|
-
}
|
|
274
|
-
return rendered;
|
|
275
|
-
}
|
|
276
|
-
|
|
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
|
-
}
|
|
301
|
+
// Adds the verification result to each route record for the JSON report.
|
|
302
|
+
function mergeVerification(report) {
|
|
303
|
+
for (const entry of report.verification?.routes ?? []) {
|
|
304
|
+
const detail = report.routeDetails.find((record) => record.route === entry.route);
|
|
305
|
+
if (!detail) continue;
|
|
306
|
+
detail.mode = entry.mode;
|
|
307
|
+
detail.framework = entry.framework ?? null;
|
|
308
|
+
detail.hydrationErrors = entry.hydrationErrors;
|
|
309
|
+
detail.pageErrors = [...new Set([...(detail.pageErrors ?? []), ...entry.pageErrors])];
|
|
310
|
+
detail.consoleErrors = [...new Set([...(detail.consoleErrors ?? []), ...entry.consoleErrors])];
|
|
311
|
+
detail.verificationStatus = entry.status;
|
|
312
|
+
detail.empty = entry.empty || detail.empty;
|
|
313
|
+
detail.firstMismatch = entry.firstMismatch ?? null;
|
|
306
314
|
}
|
|
307
|
-
return verifiable;
|
|
308
315
|
}
|
|
309
316
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
});
|
|
317
|
+
// Prints an audit summary and a bounded sample of findings.
|
|
318
|
+
function logAudit(audit, log) {
|
|
319
|
+
const errors = audit?.errors?.length ?? 0;
|
|
320
|
+
const warnings = audit?.warnings?.length ?? 0;
|
|
321
|
+
if (errors === 0 && warnings === 0) {
|
|
322
|
+
log.success(`Audit: no issues (${audit?.linksChecked ?? 0} links checked)`);
|
|
323
|
+
return;
|
|
428
324
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
);
|
|
325
|
+
log.info(`Audit: ${errors} error(s), ${warnings} warning(s)`);
|
|
326
|
+
for (const finding of [...(audit?.errors ?? []), ...(audit?.warnings ?? [])].slice(0, 20)) {
|
|
327
|
+
const message = `${finding.rule}: ${finding.message}`;
|
|
328
|
+
if (finding.severity === 'error') log.error(` ${message}`);
|
|
329
|
+
else log.warn(` ${message}`);
|
|
434
330
|
}
|
|
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
331
|
}
|
|
449
332
|
|
|
450
|
-
// Notes
|
|
451
|
-
//
|
|
333
|
+
// Notes duplicate title tags, which React 19 native metadata and the head layer can
|
|
334
|
+
// each produce.
|
|
452
335
|
function logDuplicateTitles(report, log) {
|
|
453
336
|
if (report.duplicateTitles.length === 0) return;
|
|
454
337
|
const sample = report.duplicateTitles.slice(0, 5).join(', ');
|
|
@@ -457,78 +340,6 @@ function logDuplicateTitles(report, log) {
|
|
|
457
340
|
);
|
|
458
341
|
}
|
|
459
342
|
|
|
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
343
|
// Notes when a not-found route is configured but was never rendered, so no 404.html
|
|
533
344
|
// was written and a static host would serve its own default page instead.
|
|
534
345
|
function logNotFoundNotice(report, config, log) {
|
|
@@ -560,12 +371,24 @@ function logFileSummary(report, log) {
|
|
|
560
371
|
// markup, surfaces errors the client logged while booting, and notes when hydration failures
|
|
561
372
|
// were reported but not enforced.
|
|
562
373
|
function logVerification(verification, config, log) {
|
|
374
|
+
const modes = verification.modes ?? {};
|
|
375
|
+
log.info(
|
|
376
|
+
`Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
|
|
377
|
+
);
|
|
378
|
+
const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
|
|
379
|
+
if (notDetected > 0) {
|
|
380
|
+
log.warn(
|
|
381
|
+
`${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'}.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
563
384
|
if (verification.ok) {
|
|
564
385
|
log.success(`Hydration verified for ${verification.routes.length} route(s)`);
|
|
565
386
|
} else {
|
|
566
387
|
const failed = verification.routes.filter((route) => !route.ok);
|
|
567
388
|
for (const entry of failed) {
|
|
568
389
|
log.warn(`Hydration failed on ${entry.route}:`);
|
|
390
|
+
if (entry.status && entry.status >= 400) log.warn(` HTTP ${entry.status}`);
|
|
391
|
+
if (entry.empty) log.warn(' the page rendered no app content');
|
|
569
392
|
for (const message of entry.hydrationErrors) log.warn(` ${message.split('\n')[0]}`);
|
|
570
393
|
}
|
|
571
394
|
for (const error of verification.errors) {
|
|
@@ -576,6 +399,11 @@ function logVerification(verification, config, log) {
|
|
|
576
399
|
`Hydration failures are reported, not enforced, so the prerendered HTML still shipped. Set failOnHydrationError to fail the build instead.`,
|
|
577
400
|
);
|
|
578
401
|
}
|
|
402
|
+
if (verification.errors.length > 0 && !config.failOnVerificationError) {
|
|
403
|
+
log.warn(
|
|
404
|
+
'Verification could not run on some routes; the prerendered HTML still shipped. Set failOnVerificationError to fail the build instead.',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
579
407
|
}
|
|
580
408
|
|
|
581
409
|
for (const entry of verification.routes) {
|