snappy-prerender 2.6.0-rc.2 → 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 +96 -19
- package/package.json +11 -6
- package/src/cli.js +119 -12
- 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 +143 -3
- 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 +71 -1
- 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 +191 -241
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +94 -0
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +18 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
package/src/cli.js
CHANGED
|
@@ -30,16 +30,33 @@ Options:
|
|
|
30
30
|
--max-depth <n> Crawl depth limit, 0 renders roots only (default: unlimited)
|
|
31
31
|
--max-routes <n> Stop after this many routes and fail the run (default: unlimited)
|
|
32
32
|
--concurrency <n> Parallel browser pages (default: derived from CPU count)
|
|
33
|
-
--timeout <ms> Per-route timeout (default: 30000)
|
|
33
|
+
--timeout <ms> Per-route navigation/settle timeout (default: 30000)
|
|
34
|
+
--quiet-period <ms> Network must be idle this long before capture (default: 500)
|
|
35
|
+
--scroll-step-delay <ms> Delay between scroll steps (default: 100)
|
|
36
|
+
--viewport <WxH> Browser viewport (default: 1280x720)
|
|
37
|
+
--ready-selector <sel> Selector awaited when set
|
|
38
|
+
--ready-flag <name> Window flag awaited when defined (default: __prerenderReady)
|
|
39
|
+
--no-headless Run the browser visibly
|
|
34
40
|
--wait-for <selector> Wait for a selector on every route before capture
|
|
35
41
|
--browser <choice> auto | chrome | msedge | chromium | path (default: auto)
|
|
36
42
|
--no-browser-download Refuse the Chrome for Testing fallback download
|
|
37
43
|
--browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
|
|
44
|
+
--browser-download-version <build> Pin the Chrome for Testing build id
|
|
38
45
|
--storage-state <file> Playwright storage state for authenticated routes
|
|
39
46
|
--no-verify Skip the hydration verification pass
|
|
47
|
+
--verify-changed-only Skip verification for routes whose HTML did not change
|
|
40
48
|
--fail-on-hydration-error Fail the build when hydration errors are found
|
|
41
49
|
--fail-on-rerender Fail when a route re-renders instead of hydrating
|
|
42
50
|
--fail-on-page-error Fail when a page throws while it is being rendered
|
|
51
|
+
--no-fail-on-error Report route errors without failing the run
|
|
52
|
+
--fail-on-empty-page Fail when a route renders no app content (default: on)
|
|
53
|
+
--no-fail-on-empty-page Allow an empty page and report it instead
|
|
54
|
+
--fail-on-console-error Fail when the browser logs a console error while rendering
|
|
55
|
+
--fail-on-failed-request Fail when a same-origin request fails while rendering
|
|
56
|
+
--fail-on-duplicate-content Fail when two routes produce identical HTML
|
|
57
|
+
--fail-on-build-origin-metadata Fail when metadata still points at the prerender server
|
|
58
|
+
--fail-on-undetected Fail when verification cannot find a framework root
|
|
59
|
+
--fail-on-verification-error Fail when the verification pass cannot run
|
|
43
60
|
--block-third-party Block third-party requests while rendering
|
|
44
61
|
--no-block-third-party Allow third-party requests (the default)
|
|
45
62
|
--allowed-hosts <hosts> Third-party hosts to allow while blocking the rest, comma separated
|
|
@@ -50,6 +67,7 @@ Options:
|
|
|
50
67
|
--flat Write about.html instead of about/index.html
|
|
51
68
|
--not-found <route> Route emitted as 404.html (default: /404)
|
|
52
69
|
--destination <dir> Write output to another directory (default: in place)
|
|
70
|
+
--clean-destination Empty the destination before copying the build output
|
|
53
71
|
--save-as <format> html | png | jpeg (default: html)
|
|
54
72
|
--user-agent <value> User agent used while rendering (default: SnappyPrerender)
|
|
55
73
|
--metadata-site-url <url> Public site URL for canonical and og:url
|
|
@@ -57,6 +75,7 @@ Options:
|
|
|
57
75
|
--metadata-title-template <t> Title template, for example '%s | Example'
|
|
58
76
|
--metadata-default-image <url> Share image for routes without one
|
|
59
77
|
--metadata-trailing-slash <mode> preserve | always | never
|
|
78
|
+
--metadata-base <path> Base path used for absolute metadata URLs
|
|
60
79
|
--inline-css <strategy> inline | critical (critical needs the beasties package)
|
|
61
80
|
--critical-css-preload <mode> Swap for deferred CSS: none | body | media | swap | swap-high | swap-low | js | js-lazy
|
|
62
81
|
--minify-html Minify the generated HTML
|
|
@@ -65,18 +84,25 @@ Options:
|
|
|
65
84
|
--no-preconnect-third-party Disable preconnect hints
|
|
66
85
|
--preload-images Add preload hints for images on the page
|
|
67
86
|
--preload-manifest Write preload-manifest.json with Link header hints
|
|
87
|
+
--sitemap Write sitemap.xml from the rendered routes (needs metadata.siteUrl)
|
|
68
88
|
--ignore-for-preload <names> File names excluded from the manifest, comma separated
|
|
69
89
|
--cache-ajax-requests Expose captured JSON responses as window.snapStore
|
|
90
|
+
--max-cached-bytes <n> Largest JSON response cached per request (default: 5242880)
|
|
70
91
|
--remove-scripts Strip every script tag from the output
|
|
71
92
|
--remove-styles Strip every style tag from the output
|
|
72
93
|
--async-scripts Mark external scripts async
|
|
73
94
|
--external-scripts Write injected state scripts as same-origin files, for a strict CSP
|
|
74
95
|
--csp <mode> off | strict: strict makes the output work under script-src 'self'
|
|
75
96
|
--no-remove-blobs Keep blob stylesheet links instead of dropping them
|
|
97
|
+
--remove-attributes <names> Drop these attributes from the output, comma separated
|
|
98
|
+
--no-strip-build-origin-metadata Keep tags that point at the prerender server
|
|
99
|
+
--prune-stale-routes Delete pages listed by the previous run's manifest
|
|
76
100
|
--ignore-https-errors Ignore TLS errors while rendering
|
|
77
101
|
--browser-args <args> Extra browser launch arguments, comma separated
|
|
78
102
|
--url <url> App URL to connect to when using --serve-cmd
|
|
79
103
|
--serve-cmd <command> Command that starts the app server instead of static serving
|
|
104
|
+
--serve-cmd-timeout <ms> How long to wait for the command server (default: 30000)
|
|
105
|
+
--shutdown-timeout <ms> Grace period before force-killing the server (default: 5000)
|
|
80
106
|
--dry-run Render and report without writing files
|
|
81
107
|
--log-level <level> silent | error | warn | info | debug (default: info)
|
|
82
108
|
--yes, -y Accept every default, for a shell without a terminal
|
|
@@ -102,15 +128,34 @@ async function main() {
|
|
|
102
128
|
'max-routes': { type: 'string' },
|
|
103
129
|
concurrency: { type: 'string' },
|
|
104
130
|
timeout: { type: 'string' },
|
|
131
|
+
'quiet-period': { type: 'string' },
|
|
132
|
+
'scroll-step-delay': { type: 'string' },
|
|
133
|
+
'max-cached-bytes': { type: 'string' },
|
|
134
|
+
'serve-cmd-timeout': { type: 'string' },
|
|
135
|
+
'shutdown-timeout': { type: 'string' },
|
|
136
|
+
viewport: { type: 'string' },
|
|
137
|
+
'ready-flag': { type: 'string' },
|
|
138
|
+
'ready-selector': { type: 'string' },
|
|
139
|
+
headless: { type: 'boolean' },
|
|
105
140
|
'wait-for': { type: 'string' },
|
|
106
141
|
browser: { type: 'string' },
|
|
107
142
|
'no-browser-download': { type: 'boolean' },
|
|
108
143
|
'browser-download-hash': { type: 'string' },
|
|
144
|
+
'browser-download-version': { type: 'string' },
|
|
109
145
|
'storage-state': { type: 'string' },
|
|
110
146
|
'no-verify': { type: 'boolean' },
|
|
147
|
+
'verify-changed-only': { type: 'boolean' },
|
|
111
148
|
'fail-on-hydration-error': { type: 'boolean' },
|
|
149
|
+
'fail-on-error': { type: 'boolean' },
|
|
112
150
|
'fail-on-rerender': { type: 'boolean' },
|
|
113
151
|
'fail-on-page-error': { type: 'boolean' },
|
|
152
|
+
'fail-on-empty-page': { type: 'boolean' },
|
|
153
|
+
'fail-on-console-error': { type: 'boolean' },
|
|
154
|
+
'fail-on-failed-request': { type: 'boolean' },
|
|
155
|
+
'fail-on-duplicate-content': { type: 'boolean' },
|
|
156
|
+
'fail-on-build-origin-metadata': { type: 'boolean' },
|
|
157
|
+
'fail-on-undetected': { type: 'boolean' },
|
|
158
|
+
'fail-on-verification-error': { type: 'boolean' },
|
|
114
159
|
'no-block-third-party': { type: 'boolean' },
|
|
115
160
|
'block-third-party': { type: 'boolean' },
|
|
116
161
|
'allowed-hosts': { type: 'string', multiple: true },
|
|
@@ -121,6 +166,7 @@ async function main() {
|
|
|
121
166
|
flat: { type: 'boolean' },
|
|
122
167
|
'not-found': { type: 'string' },
|
|
123
168
|
destination: { type: 'string' },
|
|
169
|
+
'clean-destination': { type: 'boolean' },
|
|
124
170
|
'save-as': { type: 'string' },
|
|
125
171
|
'user-agent': { type: 'string' },
|
|
126
172
|
'metadata-site-url': { type: 'string' },
|
|
@@ -128,14 +174,17 @@ async function main() {
|
|
|
128
174
|
'metadata-title-template': { type: 'string' },
|
|
129
175
|
'metadata-default-image': { type: 'string' },
|
|
130
176
|
'metadata-trailing-slash': { type: 'string' },
|
|
177
|
+
'metadata-base': { type: 'string' },
|
|
131
178
|
'inline-css': { type: 'string' },
|
|
132
179
|
'critical-css-preload': { type: 'string' },
|
|
133
180
|
csp: { type: 'string' },
|
|
134
181
|
'minify-html': { type: 'boolean' },
|
|
135
182
|
'minify-css': { type: 'boolean' },
|
|
136
183
|
'no-preconnect-third-party': { type: 'boolean' },
|
|
184
|
+
'preconnect-third-party': { type: 'boolean' },
|
|
137
185
|
'preload-images': { type: 'boolean' },
|
|
138
186
|
'preload-manifest': { type: 'boolean' },
|
|
187
|
+
sitemap: { type: 'boolean' },
|
|
139
188
|
'ignore-for-preload': { type: 'string', multiple: true },
|
|
140
189
|
'cache-ajax-requests': { type: 'boolean' },
|
|
141
190
|
'remove-scripts': { type: 'boolean' },
|
|
@@ -143,6 +192,9 @@ async function main() {
|
|
|
143
192
|
'async-scripts': { type: 'boolean' },
|
|
144
193
|
'external-scripts': { type: 'boolean' },
|
|
145
194
|
'no-remove-blobs': { type: 'boolean' },
|
|
195
|
+
'remove-attributes': { type: 'string', multiple: true },
|
|
196
|
+
'no-strip-build-origin-metadata': { type: 'boolean' },
|
|
197
|
+
'prune-stale-routes': { type: 'boolean' },
|
|
146
198
|
'ignore-https-errors': { type: 'boolean' },
|
|
147
199
|
'browser-args': { type: 'string', multiple: true },
|
|
148
200
|
url: { type: 'string' },
|
|
@@ -199,17 +251,52 @@ function cliOptions({ values, positionals }) {
|
|
|
199
251
|
if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
|
|
200
252
|
if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
|
|
201
253
|
if (values.timeout !== undefined) options.timeout = Number(values.timeout);
|
|
254
|
+
if (values['quiet-period'] !== undefined) options.quietPeriod = Number(values['quiet-period']);
|
|
255
|
+
if (values['scroll-step-delay'] !== undefined) {
|
|
256
|
+
options.scrollStepDelay = Number(values['scroll-step-delay']);
|
|
257
|
+
}
|
|
258
|
+
if (values['max-cached-bytes'] !== undefined) {
|
|
259
|
+
options.maxCachedBytes = Number(values['max-cached-bytes']);
|
|
260
|
+
}
|
|
261
|
+
if (values['serve-cmd-timeout'] !== undefined) {
|
|
262
|
+
options.serveCmdTimeout = Number(values['serve-cmd-timeout']);
|
|
263
|
+
}
|
|
264
|
+
if (values['shutdown-timeout'] !== undefined) {
|
|
265
|
+
options.shutdownTimeout = Number(values['shutdown-timeout']);
|
|
266
|
+
}
|
|
267
|
+
if (values.viewport !== undefined) {
|
|
268
|
+
const match = /^(\d+)x(\d+)$/i.exec(values.viewport);
|
|
269
|
+
if (!match) throw new Error('viewport must look like 1280x720');
|
|
270
|
+
options.viewport = { width: Number(match[1]), height: Number(match[2]) };
|
|
271
|
+
}
|
|
272
|
+
if (values['ready-selector'] !== undefined) options.readySelector = values['ready-selector'];
|
|
273
|
+
if (values['ready-flag'] !== undefined) options.readyFlag = values['ready-flag'];
|
|
274
|
+
if (values.headless !== undefined) options.headless = values.headless;
|
|
202
275
|
if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
|
|
203
276
|
if (values.browser !== undefined) options.browser = values.browser;
|
|
204
277
|
if (values['no-browser-download']) options.browserDownload = false;
|
|
205
278
|
if (values['browser-download-hash'] !== undefined) {
|
|
206
279
|
options.browserDownloadHash = values['browser-download-hash'];
|
|
207
280
|
}
|
|
281
|
+
if (values['browser-download-version'] !== undefined) {
|
|
282
|
+
options.browserDownloadVersion = values['browser-download-version'];
|
|
283
|
+
}
|
|
208
284
|
if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
|
|
209
285
|
if (values['no-verify']) options.verify = false;
|
|
286
|
+
if (values['verify-changed-only']) options.verifyChangedOnly = true;
|
|
210
287
|
if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
|
|
211
288
|
if (values['fail-on-rerender']) options.failOnRerender = true;
|
|
212
289
|
if (values['fail-on-page-error']) options.failOnPageError = true;
|
|
290
|
+
if (values['fail-on-error'] !== undefined) options.failOnError = values['fail-on-error'];
|
|
291
|
+
if (values['fail-on-empty-page'] !== undefined) {
|
|
292
|
+
options.failOnEmptyPage = values['fail-on-empty-page'];
|
|
293
|
+
}
|
|
294
|
+
if (values['fail-on-console-error']) options.failOnConsoleError = true;
|
|
295
|
+
if (values['fail-on-failed-request']) options.failOnFailedRequest = true;
|
|
296
|
+
if (values['fail-on-duplicate-content']) options.failOnDuplicateContent = true;
|
|
297
|
+
if (values['fail-on-build-origin-metadata']) options.failOnBuildOriginMetadata = true;
|
|
298
|
+
if (values['fail-on-undetected']) options.failOnUndetected = true;
|
|
299
|
+
if (values['fail-on-verification-error']) options.failOnVerificationError = true;
|
|
213
300
|
if (values['no-block-third-party']) options.blockThirdParty = false;
|
|
214
301
|
if (values['block-third-party']) options.blockThirdParty = true;
|
|
215
302
|
if (values['allowed-hosts'] !== undefined)
|
|
@@ -221,6 +308,7 @@ function cliOptions({ values, positionals }) {
|
|
|
221
308
|
if (values.flat) options.flatOutput = true;
|
|
222
309
|
if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
|
|
223
310
|
if (values.destination !== undefined) options.destination = values.destination;
|
|
311
|
+
if (values['clean-destination']) options.cleanDestination = true;
|
|
224
312
|
if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
|
|
225
313
|
if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
|
|
226
314
|
const metadata = metadataOptions(values);
|
|
@@ -234,8 +322,10 @@ function cliOptions({ values, positionals }) {
|
|
|
234
322
|
if (values['minify-html']) options.minifyHtml = true;
|
|
235
323
|
if (values['minify-css']) options.minifyCss = true;
|
|
236
324
|
if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
|
|
325
|
+
if (values['preconnect-third-party']) options.preconnectThirdParty = true;
|
|
237
326
|
if (values['preload-images']) options.preloadImages = true;
|
|
238
327
|
if (values['preload-manifest']) options.preloadManifest = true;
|
|
328
|
+
if (values.sitemap) options.sitemap = true;
|
|
239
329
|
if (values['ignore-for-preload'] !== undefined) {
|
|
240
330
|
options.ignoreForPreload = splitList(values['ignore-for-preload']);
|
|
241
331
|
}
|
|
@@ -245,6 +335,11 @@ function cliOptions({ values, positionals }) {
|
|
|
245
335
|
if (values['async-scripts']) options.asyncScriptTags = true;
|
|
246
336
|
if (values['external-scripts']) options.externalScripts = true;
|
|
247
337
|
if (values['no-remove-blobs']) options.removeBlobs = false;
|
|
338
|
+
if (values['remove-attributes'] !== undefined) {
|
|
339
|
+
options.removeAttributes = splitList(values['remove-attributes']);
|
|
340
|
+
}
|
|
341
|
+
if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
|
|
342
|
+
if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
|
|
248
343
|
if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
|
|
249
344
|
if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
|
|
250
345
|
if (values.url !== undefined) options.url = values.url;
|
|
@@ -287,14 +382,14 @@ async function runInit({ values }) {
|
|
|
287
382
|
force: values.force === true,
|
|
288
383
|
});
|
|
289
384
|
log.success(`Wrote ${CONFIG_FILE}`);
|
|
290
|
-
printNextSteps({ detected, log });
|
|
385
|
+
printNextSteps({ detected, log, tag: readVersion().includes('-') ? '@next' : '' });
|
|
291
386
|
}
|
|
292
387
|
|
|
293
388
|
// Turns the flags the CLI already understands into the wizard's starting answers.
|
|
294
389
|
function defaultsFromFlags(detected, flags) {
|
|
295
390
|
return {
|
|
296
391
|
sourceDir: flags.sourceDir ?? detected.sourceDir,
|
|
297
|
-
base: flags.base ?? '/',
|
|
392
|
+
base: flags.base ?? detected.base ?? '/',
|
|
298
393
|
include: flags.include ?? ['/'],
|
|
299
394
|
crawl: flags.crawl ?? true,
|
|
300
395
|
siteUrl: flags.metadata?.siteUrl ?? '',
|
|
@@ -377,9 +472,9 @@ function validatedAnswers(answers, log) {
|
|
|
377
472
|
}
|
|
378
473
|
|
|
379
474
|
// Prints what to do next: install if needed, run it, and where the head layer goes.
|
|
380
|
-
function printNextSteps({ detected, log }) {
|
|
475
|
+
function printNextSteps({ detected, log, tag = '' }) {
|
|
381
476
|
const run = runnerCommand(detected.runner, PACKAGE_NAME);
|
|
382
|
-
const install = installCommand(detected.runner);
|
|
477
|
+
const install = installCommand(detected.runner, tag);
|
|
383
478
|
log.info('');
|
|
384
479
|
log.info('Next steps:');
|
|
385
480
|
if (!detected.installed) log.info(` Install ${install}`);
|
|
@@ -408,10 +503,11 @@ function runnerCommand(runner, binary) {
|
|
|
408
503
|
}
|
|
409
504
|
|
|
410
505
|
// Builds the command that installs this package as a dev dependency.
|
|
411
|
-
function installCommand(runner) {
|
|
412
|
-
|
|
413
|
-
if (runner === '
|
|
414
|
-
return `
|
|
506
|
+
function installCommand(runner, tag = '') {
|
|
507
|
+
const pkg = `${PACKAGE_NAME}${tag}`;
|
|
508
|
+
if (runner === 'pnpm') return `pnpm add -D ${pkg}`;
|
|
509
|
+
if (runner === 'yarn') return `yarn add -D ${pkg}`;
|
|
510
|
+
return `npm install --save-dev ${pkg}`;
|
|
415
511
|
}
|
|
416
512
|
|
|
417
513
|
// Collects the metadata flags, or null when none was passed so a config file value survives.
|
|
@@ -430,12 +526,15 @@ function metadataOptions(values) {
|
|
|
430
526
|
if (values['metadata-trailing-slash'] !== undefined) {
|
|
431
527
|
metadata.trailingSlash = values['metadata-trailing-slash'];
|
|
432
528
|
}
|
|
529
|
+
if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
|
|
433
530
|
return Object.keys(metadata).length > 0 ? metadata : null;
|
|
434
531
|
}
|
|
435
532
|
|
|
436
533
|
/**
|
|
437
534
|
* Loads a JS config file. An explicitly requested file must exist; the implicit
|
|
438
|
-
* snappy.config.js is optional. The default export
|
|
535
|
+
* snappy.config.js is optional. The default export is either a plain options object or an
|
|
536
|
+
* async function that returns one, which is how routes are computed from a CMS, a sitemap or
|
|
537
|
+
* a file listing without a separate prebuild script.
|
|
439
538
|
*/
|
|
440
539
|
async function loadConfigFile(explicitPath) {
|
|
441
540
|
if (explicitPath === undefined) {
|
|
@@ -445,9 +544,17 @@ async function loadConfigFile(explicitPath) {
|
|
|
445
544
|
throw new Error(`Config file not found: ${explicitPath}`);
|
|
446
545
|
}
|
|
447
546
|
const module = await import(pathToFileURL(explicitPath).href);
|
|
448
|
-
const
|
|
547
|
+
const exported = module.default ?? {};
|
|
548
|
+
let loaded;
|
|
549
|
+
try {
|
|
550
|
+
loaded = typeof exported === 'function' ? await exported() : exported;
|
|
551
|
+
} catch (error) {
|
|
552
|
+
throw new Error(`Config file failed to load: ${error.message}`);
|
|
553
|
+
}
|
|
449
554
|
if (typeof loaded !== 'object' || loaded === null || Array.isArray(loaded)) {
|
|
450
|
-
throw new Error(
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Config file must export a plain options object or a function returning one: ${explicitPath}`,
|
|
557
|
+
);
|
|
451
558
|
}
|
|
452
559
|
return loaded;
|
|
453
560
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeFileIfChanged } from './output.js';
|
|
4
|
+
import { buildPreloadManifest } from './preload-manifest.js';
|
|
5
|
+
import { toPublicPath } from './routes.js';
|
|
6
|
+
import { buildSitemap, withSitemapDirective } from './sitemap.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Writes the preload manifest: per route, the scripts and stylesheets the final HTML
|
|
10
|
+
* references, as Link header values a host can serve for Early Hints or HTTP headers.
|
|
11
|
+
*/
|
|
12
|
+
export async function writePreloadManifest({ manifestEntries, outputDir, config, log, report }) {
|
|
13
|
+
const manifest = buildPreloadManifest(manifestEntries, {
|
|
14
|
+
ignoreForPreload: config.ignoreForPreload,
|
|
15
|
+
base: config.base,
|
|
16
|
+
flatOutput: config.flatOutput,
|
|
17
|
+
trailingSlash: config.metadata?.trailingSlash,
|
|
18
|
+
notFoundRoute: config.notFoundRoute,
|
|
19
|
+
});
|
|
20
|
+
const file = 'preload-manifest.json';
|
|
21
|
+
const contents = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
22
|
+
if (config.dryRun) {
|
|
23
|
+
report.files.push({ route: null, file, status: 'dry-run', bytes: Buffer.byteLength(contents) });
|
|
24
|
+
log.info(`Dry run: would write preload manifest covering ${manifest.length} route(s)`);
|
|
25
|
+
return { file, routes: manifest.length, status: 'dry-run' };
|
|
26
|
+
}
|
|
27
|
+
const written = await writeFileIfChanged({ dir: outputDir, file, contents });
|
|
28
|
+
report.files.push({ route: null, ...written });
|
|
29
|
+
log.info(
|
|
30
|
+
`${written.status === 'unchanged' ? 'Unchanged' : 'Wrote'} preload manifest covering ${manifest.length} route(s)`,
|
|
31
|
+
);
|
|
32
|
+
return { file, routes: manifest.length, status: written.status };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Writes sitemap.xml from the routes the run produced, and points an existing robots.txt at
|
|
37
|
+
* it. Every loc must be absolute, which is why the option needs metadata.siteUrl.
|
|
38
|
+
*/
|
|
39
|
+
export async function writeSitemap({ outputDir, config, log, report }) {
|
|
40
|
+
if (config.saveAs !== 'html') {
|
|
41
|
+
log.warn(
|
|
42
|
+
`sitemap is skipped when saveAs writes ${config.saveAs} files, as there are no pages to list`,
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sitemap = buildSitemap({ files: report.files, config });
|
|
47
|
+
if (!sitemap) {
|
|
48
|
+
log.warn('sitemap is skipped: no pages were written');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const siteUrl = config.metadata.siteUrl.replace(/\/$/, '');
|
|
53
|
+
const sitemapUrl = `${siteUrl}${toPublicPath('/sitemap.xml', config.base)}`;
|
|
54
|
+
const robots = await fs.readFile(path.join(outputDir, 'robots.txt'), 'utf8').catch(() => null);
|
|
55
|
+
const patched = robots === null ? null : withSitemapDirective(robots, sitemapUrl);
|
|
56
|
+
const names = sitemap.files.map((entry) => entry.name);
|
|
57
|
+
|
|
58
|
+
if (config.dryRun) {
|
|
59
|
+
for (const entry of sitemap.files) {
|
|
60
|
+
report.files.push({
|
|
61
|
+
route: null,
|
|
62
|
+
file: entry.name,
|
|
63
|
+
status: 'dry-run',
|
|
64
|
+
bytes: Buffer.byteLength(entry.contents),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
report.sitemap = {
|
|
68
|
+
file: 'sitemap.xml',
|
|
69
|
+
routes: sitemap.routes,
|
|
70
|
+
robotsUpdated: patched !== null,
|
|
71
|
+
files: names,
|
|
72
|
+
};
|
|
73
|
+
log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const statuses = [];
|
|
78
|
+
for (const entry of sitemap.files) {
|
|
79
|
+
const written = await writeFileIfChanged({
|
|
80
|
+
dir: outputDir,
|
|
81
|
+
file: entry.name,
|
|
82
|
+
contents: entry.contents,
|
|
83
|
+
});
|
|
84
|
+
statuses.push(written.status);
|
|
85
|
+
report.files.push({ route: null, ...written });
|
|
86
|
+
}
|
|
87
|
+
report.sitemap = {
|
|
88
|
+
file: 'sitemap.xml',
|
|
89
|
+
routes: sitemap.routes,
|
|
90
|
+
robotsUpdated: patched !== null,
|
|
91
|
+
files: names,
|
|
92
|
+
};
|
|
93
|
+
log.info(
|
|
94
|
+
`${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (robots === null) {
|
|
98
|
+
log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (patched !== null) {
|
|
102
|
+
const written = await writeFileIfChanged({
|
|
103
|
+
dir: outputDir,
|
|
104
|
+
file: 'robots.txt',
|
|
105
|
+
contents: patched,
|
|
106
|
+
});
|
|
107
|
+
report.files.push({ route: null, ...written });
|
|
108
|
+
log.info('Added the sitemap location to robots.txt');
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/core/blocking.js
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports whether a host is on the allowlist. Entries may be a hostname, a hostname
|
|
3
|
+
* with a port, a leading-dot or wildcard suffix, or a full URL; matching is
|
|
4
|
+
* case-insensitive.
|
|
5
|
+
*/
|
|
6
|
+
export function isHostAllowed(hostname, port, allowedHosts) {
|
|
7
|
+
const host = hostname.toLowerCase();
|
|
8
|
+
const withPort = port ? `${host}:${port}` : host;
|
|
9
|
+
for (const raw of allowedHosts) {
|
|
10
|
+
const entry = String(raw).trim().toLowerCase();
|
|
11
|
+
if (entry.length === 0) continue;
|
|
12
|
+
if (entry.includes('://')) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(entry);
|
|
15
|
+
if (parsed.hostname.toLowerCase() === host && (!parsed.port || parsed.port === port)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// A malformed entry is simply not a match.
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (entry === withPort || entry === host) return true;
|
|
24
|
+
if (entry.startsWith('*.') && host.endsWith(entry.slice(1)) && host !== entry.slice(2)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (entry.startsWith('.') && host.endsWith(entry) && host !== entry.slice(1)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
/**
|
|
2
35
|
* Aborts requests to third-party origins so analytics, ads and external widgets
|
|
3
36
|
* cannot mutate the DOM mid-capture or make output non-deterministic. Used by both
|
|
@@ -15,11 +48,30 @@ export async function blockThirdPartyRequests(page, origin, allowedHosts) {
|
|
|
15
48
|
return allow(route);
|
|
16
49
|
}
|
|
17
50
|
if (parsed.origin === origin) return allow(route);
|
|
18
|
-
if (
|
|
51
|
+
if (isHostAllowed(parsed.hostname, parsed.port, allowed)) return allow(route);
|
|
19
52
|
// A route can already be handled or cancelled, and that rejection is not this hook's to
|
|
20
53
|
// report, so it is swallowed rather than surfacing as an unhandled rejection.
|
|
21
54
|
return route.abort().catch(() => {});
|
|
22
55
|
});
|
|
56
|
+
|
|
57
|
+
// page.route does not see WebSockets; route them separately when the Playwright
|
|
58
|
+
// version provides the hook.
|
|
59
|
+
if (typeof page.routeWebSocket === 'function') {
|
|
60
|
+
await page.routeWebSocket('**/*', (socket) => {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(socket.url());
|
|
64
|
+
} catch {
|
|
65
|
+
socket.close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parsed.origin === origin || isHostAllowed(parsed.hostname, parsed.port, allowed)) {
|
|
69
|
+
socket.connectToServer();
|
|
70
|
+
} else {
|
|
71
|
+
socket.close();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
23
75
|
}
|
|
24
76
|
|
|
25
77
|
// Continues a request, ignoring the rejection that follows an already-handled route.
|