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/cli.js
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import process from 'node:process';
|
|
4
|
-
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import {
|
|
8
|
-
CONFIG_FILE,
|
|
9
|
-
detectProjectDefaults,
|
|
10
|
-
PACKAGE_NAME,
|
|
11
|
-
parseRouteList,
|
|
12
|
-
renderConfigFile,
|
|
13
|
-
writeConfigFile,
|
|
14
|
-
} from './core/init.js';
|
|
7
|
+
import { runInit } from './cli-init.js';
|
|
15
8
|
import { createLogger } from './core/log.js';
|
|
16
9
|
import { prerender } from './core/run.js';
|
|
17
10
|
|
|
@@ -30,17 +23,33 @@ Options:
|
|
|
30
23
|
--max-depth <n> Crawl depth limit, 0 renders roots only (default: unlimited)
|
|
31
24
|
--max-routes <n> Stop after this many routes and fail the run (default: unlimited)
|
|
32
25
|
--concurrency <n> Parallel browser pages (default: derived from CPU count)
|
|
33
|
-
--timeout <ms> Per-route timeout (default: 30000)
|
|
26
|
+
--timeout <ms> Per-route navigation/settle timeout (default: 30000)
|
|
27
|
+
--quiet-period <ms> Network must be idle this long before capture (default: 500)
|
|
28
|
+
--scroll-step-delay <ms> Delay between scroll steps (default: 100)
|
|
29
|
+
--viewport <WxH> Browser viewport (default: 1280x720)
|
|
30
|
+
--ready-selector <sel> Selector awaited when set
|
|
31
|
+
--ready-flag <name> Window flag awaited when defined (default: __prerenderReady)
|
|
32
|
+
--no-headless Run the browser visibly
|
|
34
33
|
--wait-for <selector> Wait for a selector on every route before capture
|
|
35
34
|
--browser <choice> auto | chrome | msedge | chromium | path (default: auto)
|
|
36
35
|
--no-browser-download Refuse the Chrome for Testing fallback download
|
|
37
36
|
--browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
|
|
37
|
+
--browser-download-version <build> Pin the Chrome for Testing build id
|
|
38
38
|
--storage-state <file> Playwright storage state for authenticated routes
|
|
39
39
|
--no-verify Skip the hydration verification pass
|
|
40
40
|
--verify-changed-only Skip verification for routes whose HTML did not change
|
|
41
41
|
--fail-on-hydration-error Fail the build when hydration errors are found
|
|
42
42
|
--fail-on-rerender Fail when a route re-renders instead of hydrating
|
|
43
43
|
--fail-on-page-error Fail when a page throws while it is being rendered
|
|
44
|
+
--no-fail-on-error Report route errors without failing the run
|
|
45
|
+
--fail-on-empty-page Fail when a route renders no app content (default: on)
|
|
46
|
+
--no-fail-on-empty-page Allow an empty page and report it instead
|
|
47
|
+
--fail-on-console-error Fail when the browser logs a console error while rendering
|
|
48
|
+
--fail-on-failed-request Fail when a same-origin request fails while rendering
|
|
49
|
+
--fail-on-duplicate-content Fail when two routes produce identical HTML
|
|
50
|
+
--fail-on-build-origin-metadata Fail when metadata still points at the prerender server
|
|
51
|
+
--fail-on-undetected Fail when verification cannot find a framework root
|
|
52
|
+
--fail-on-verification-error Fail when the verification pass cannot run
|
|
44
53
|
--block-third-party Block third-party requests while rendering
|
|
45
54
|
--no-block-third-party Allow third-party requests (the default)
|
|
46
55
|
--allowed-hosts <hosts> Third-party hosts to allow while blocking the rest, comma separated
|
|
@@ -50,7 +59,22 @@ Options:
|
|
|
50
59
|
--scroll Scroll through each page to trigger lazy content
|
|
51
60
|
--flat Write about.html instead of about/index.html
|
|
52
61
|
--not-found <route> Route emitted as 404.html (default: /404)
|
|
62
|
+
--routes-file <file> Read route seeds from a text/JSON file
|
|
63
|
+
--routes-from-sitemap <path-or-url> Seed routes from a local or remote sitemap
|
|
64
|
+
--route-priority <patterns> Patterns ranked first when crawling, comma separated
|
|
65
|
+
--report <file> Write a machine-readable run report (JSON)
|
|
66
|
+
--audit Check the written site for broken links and SEO issues (default: on)
|
|
67
|
+
--no-audit Skip the site audit
|
|
68
|
+
--fail-on-audit-error Fail when the audit finds an error
|
|
69
|
+
--audit-ignore-links <patterns> Link patterns excluded from the audit, comma separated
|
|
70
|
+
--preload-links Inject module, font and LCP image preload hints into each page
|
|
71
|
+
--image-sitemap List page images in the sitemap
|
|
72
|
+
--framework <name> auto | react | vue | svelte | preact (default: auto)
|
|
73
|
+
--retries <n> Retry a failed render this many times (default: 0)
|
|
74
|
+
--retry-delay <ms> Wait between render attempts (default: 500)
|
|
75
|
+
--trace-on-error Write a Playwright trace for the final failed attempt
|
|
53
76
|
--destination <dir> Write output to another directory (default: in place)
|
|
77
|
+
--clean-destination Empty the destination before copying the build output
|
|
54
78
|
--save-as <format> html | png | jpeg (default: html)
|
|
55
79
|
--user-agent <value> User agent used while rendering (default: SnappyPrerender)
|
|
56
80
|
--metadata-site-url <url> Public site URL for canonical and og:url
|
|
@@ -58,6 +82,7 @@ Options:
|
|
|
58
82
|
--metadata-title-template <t> Title template, for example '%s | Example'
|
|
59
83
|
--metadata-default-image <url> Share image for routes without one
|
|
60
84
|
--metadata-trailing-slash <mode> preserve | always | never
|
|
85
|
+
--metadata-base <path> Base path used for absolute metadata URLs
|
|
61
86
|
--inline-css <strategy> inline | critical (critical needs the beasties package)
|
|
62
87
|
--critical-css-preload <mode> Swap for deferred CSS: none | body | media | swap | swap-high | swap-low | js | js-lazy
|
|
63
88
|
--minify-html Minify the generated HTML
|
|
@@ -69,17 +94,22 @@ Options:
|
|
|
69
94
|
--sitemap Write sitemap.xml from the rendered routes (needs metadata.siteUrl)
|
|
70
95
|
--ignore-for-preload <names> File names excluded from the manifest, comma separated
|
|
71
96
|
--cache-ajax-requests Expose captured JSON responses as window.snapStore
|
|
97
|
+
--max-cached-bytes <n> Largest JSON response cached per request (default: 5242880)
|
|
72
98
|
--remove-scripts Strip every script tag from the output
|
|
73
99
|
--remove-styles Strip every style tag from the output
|
|
74
100
|
--async-scripts Mark external scripts async
|
|
75
101
|
--external-scripts Write injected state scripts as same-origin files, for a strict CSP
|
|
76
|
-
--csp <mode> off | strict: strict
|
|
102
|
+
--csp <mode> off | strict | hash: strict for script-src 'self', hash for inline hashes
|
|
77
103
|
--no-remove-blobs Keep blob stylesheet links instead of dropping them
|
|
78
104
|
--remove-attributes <names> Drop these attributes from the output, comma separated
|
|
105
|
+
--no-strip-build-origin-metadata Keep tags that point at the prerender server
|
|
106
|
+
--prune-stale-routes Delete pages listed by the previous run's manifest
|
|
79
107
|
--ignore-https-errors Ignore TLS errors while rendering
|
|
80
108
|
--browser-args <args> Extra browser launch arguments, comma separated
|
|
81
109
|
--url <url> App URL to connect to when using --serve-cmd
|
|
82
110
|
--serve-cmd <command> Command that starts the app server instead of static serving
|
|
111
|
+
--serve-cmd-timeout <ms> How long to wait for the command server (default: 30000)
|
|
112
|
+
--shutdown-timeout <ms> Grace period before force-killing the server (default: 5000)
|
|
83
113
|
--dry-run Render and report without writing files
|
|
84
114
|
--log-level <level> silent | error | warn | info | debug (default: info)
|
|
85
115
|
--yes, -y Accept every default, for a shell without a terminal
|
|
@@ -105,16 +135,34 @@ async function main() {
|
|
|
105
135
|
'max-routes': { type: 'string' },
|
|
106
136
|
concurrency: { type: 'string' },
|
|
107
137
|
timeout: { type: 'string' },
|
|
138
|
+
'quiet-period': { type: 'string' },
|
|
139
|
+
'scroll-step-delay': { type: 'string' },
|
|
140
|
+
'max-cached-bytes': { type: 'string' },
|
|
141
|
+
'serve-cmd-timeout': { type: 'string' },
|
|
142
|
+
'shutdown-timeout': { type: 'string' },
|
|
143
|
+
viewport: { type: 'string' },
|
|
144
|
+
'ready-flag': { type: 'string' },
|
|
145
|
+
'ready-selector': { type: 'string' },
|
|
146
|
+
headless: { type: 'boolean' },
|
|
108
147
|
'wait-for': { type: 'string' },
|
|
109
148
|
browser: { type: 'string' },
|
|
110
149
|
'no-browser-download': { type: 'boolean' },
|
|
111
150
|
'browser-download-hash': { type: 'string' },
|
|
151
|
+
'browser-download-version': { type: 'string' },
|
|
112
152
|
'storage-state': { type: 'string' },
|
|
113
153
|
'no-verify': { type: 'boolean' },
|
|
114
154
|
'verify-changed-only': { type: 'boolean' },
|
|
115
155
|
'fail-on-hydration-error': { type: 'boolean' },
|
|
156
|
+
'fail-on-error': { type: 'boolean' },
|
|
116
157
|
'fail-on-rerender': { type: 'boolean' },
|
|
117
158
|
'fail-on-page-error': { type: 'boolean' },
|
|
159
|
+
'fail-on-empty-page': { type: 'boolean' },
|
|
160
|
+
'fail-on-console-error': { type: 'boolean' },
|
|
161
|
+
'fail-on-failed-request': { type: 'boolean' },
|
|
162
|
+
'fail-on-duplicate-content': { type: 'boolean' },
|
|
163
|
+
'fail-on-build-origin-metadata': { type: 'boolean' },
|
|
164
|
+
'fail-on-undetected': { type: 'boolean' },
|
|
165
|
+
'fail-on-verification-error': { type: 'boolean' },
|
|
118
166
|
'no-block-third-party': { type: 'boolean' },
|
|
119
167
|
'block-third-party': { type: 'boolean' },
|
|
120
168
|
'allowed-hosts': { type: 'string', multiple: true },
|
|
@@ -124,7 +172,22 @@ async function main() {
|
|
|
124
172
|
scroll: { type: 'boolean' },
|
|
125
173
|
flat: { type: 'boolean' },
|
|
126
174
|
'not-found': { type: 'string' },
|
|
175
|
+
'routes-file': { type: 'string' },
|
|
176
|
+
'routes-from-sitemap': { type: 'string' },
|
|
177
|
+
'route-priority': { type: 'string', multiple: true },
|
|
178
|
+
report: { type: 'string' },
|
|
179
|
+
audit: { type: 'boolean' },
|
|
180
|
+
'no-audit': { type: 'boolean' },
|
|
181
|
+
'fail-on-audit-error': { type: 'boolean' },
|
|
182
|
+
'audit-ignore-links': { type: 'string', multiple: true },
|
|
183
|
+
'preload-links': { type: 'boolean' },
|
|
184
|
+
'image-sitemap': { type: 'boolean' },
|
|
185
|
+
framework: { type: 'string' },
|
|
186
|
+
retries: { type: 'string' },
|
|
187
|
+
'retry-delay': { type: 'string' },
|
|
188
|
+
'trace-on-error': { type: 'boolean' },
|
|
127
189
|
destination: { type: 'string' },
|
|
190
|
+
'clean-destination': { type: 'boolean' },
|
|
128
191
|
'save-as': { type: 'string' },
|
|
129
192
|
'user-agent': { type: 'string' },
|
|
130
193
|
'metadata-site-url': { type: 'string' },
|
|
@@ -132,12 +195,14 @@ async function main() {
|
|
|
132
195
|
'metadata-title-template': { type: 'string' },
|
|
133
196
|
'metadata-default-image': { type: 'string' },
|
|
134
197
|
'metadata-trailing-slash': { type: 'string' },
|
|
198
|
+
'metadata-base': { type: 'string' },
|
|
135
199
|
'inline-css': { type: 'string' },
|
|
136
200
|
'critical-css-preload': { type: 'string' },
|
|
137
201
|
csp: { type: 'string' },
|
|
138
202
|
'minify-html': { type: 'boolean' },
|
|
139
203
|
'minify-css': { type: 'boolean' },
|
|
140
204
|
'no-preconnect-third-party': { type: 'boolean' },
|
|
205
|
+
'preconnect-third-party': { type: 'boolean' },
|
|
141
206
|
'preload-images': { type: 'boolean' },
|
|
142
207
|
'preload-manifest': { type: 'boolean' },
|
|
143
208
|
sitemap: { type: 'boolean' },
|
|
@@ -149,6 +214,8 @@ async function main() {
|
|
|
149
214
|
'external-scripts': { type: 'boolean' },
|
|
150
215
|
'no-remove-blobs': { type: 'boolean' },
|
|
151
216
|
'remove-attributes': { type: 'string', multiple: true },
|
|
217
|
+
'no-strip-build-origin-metadata': { type: 'boolean' },
|
|
218
|
+
'prune-stale-routes': { type: 'boolean' },
|
|
152
219
|
'ignore-https-errors': { type: 'boolean' },
|
|
153
220
|
'browser-args': { type: 'string', multiple: true },
|
|
154
221
|
url: { type: 'string' },
|
|
@@ -173,7 +240,12 @@ async function main() {
|
|
|
173
240
|
return;
|
|
174
241
|
}
|
|
175
242
|
if (parsed.positionals[0] === 'init') {
|
|
176
|
-
await runInit(
|
|
243
|
+
await runInit({
|
|
244
|
+
values: parsed.values,
|
|
245
|
+
flags: cliOptions({ values: parsed.values, positionals: [] }),
|
|
246
|
+
log: createLogger(parsed.values['log-level'] ?? 'info'),
|
|
247
|
+
version: readVersion(),
|
|
248
|
+
});
|
|
177
249
|
return;
|
|
178
250
|
}
|
|
179
251
|
|
|
@@ -205,18 +277,52 @@ function cliOptions({ values, positionals }) {
|
|
|
205
277
|
if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
|
|
206
278
|
if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
|
|
207
279
|
if (values.timeout !== undefined) options.timeout = Number(values.timeout);
|
|
280
|
+
if (values['quiet-period'] !== undefined) options.quietPeriod = Number(values['quiet-period']);
|
|
281
|
+
if (values['scroll-step-delay'] !== undefined) {
|
|
282
|
+
options.scrollStepDelay = Number(values['scroll-step-delay']);
|
|
283
|
+
}
|
|
284
|
+
if (values['max-cached-bytes'] !== undefined) {
|
|
285
|
+
options.maxCachedBytes = Number(values['max-cached-bytes']);
|
|
286
|
+
}
|
|
287
|
+
if (values['serve-cmd-timeout'] !== undefined) {
|
|
288
|
+
options.serveCmdTimeout = Number(values['serve-cmd-timeout']);
|
|
289
|
+
}
|
|
290
|
+
if (values['shutdown-timeout'] !== undefined) {
|
|
291
|
+
options.shutdownTimeout = Number(values['shutdown-timeout']);
|
|
292
|
+
}
|
|
293
|
+
if (values.viewport !== undefined) {
|
|
294
|
+
const match = /^(\d+)x(\d+)$/i.exec(values.viewport);
|
|
295
|
+
if (!match) throw new Error('viewport must look like 1280x720');
|
|
296
|
+
options.viewport = { width: Number(match[1]), height: Number(match[2]) };
|
|
297
|
+
}
|
|
298
|
+
if (values['ready-selector'] !== undefined) options.readySelector = values['ready-selector'];
|
|
299
|
+
if (values['ready-flag'] !== undefined) options.readyFlag = values['ready-flag'];
|
|
300
|
+
if (values.headless !== undefined) options.headless = values.headless;
|
|
208
301
|
if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
|
|
209
302
|
if (values.browser !== undefined) options.browser = values.browser;
|
|
210
303
|
if (values['no-browser-download']) options.browserDownload = false;
|
|
211
304
|
if (values['browser-download-hash'] !== undefined) {
|
|
212
305
|
options.browserDownloadHash = values['browser-download-hash'];
|
|
213
306
|
}
|
|
307
|
+
if (values['browser-download-version'] !== undefined) {
|
|
308
|
+
options.browserDownloadVersion = values['browser-download-version'];
|
|
309
|
+
}
|
|
214
310
|
if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
|
|
215
311
|
if (values['no-verify']) options.verify = false;
|
|
216
312
|
if (values['verify-changed-only']) options.verifyChangedOnly = true;
|
|
217
313
|
if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
|
|
218
314
|
if (values['fail-on-rerender']) options.failOnRerender = true;
|
|
219
315
|
if (values['fail-on-page-error']) options.failOnPageError = true;
|
|
316
|
+
if (values['fail-on-error'] !== undefined) options.failOnError = values['fail-on-error'];
|
|
317
|
+
if (values['fail-on-empty-page'] !== undefined) {
|
|
318
|
+
options.failOnEmptyPage = values['fail-on-empty-page'];
|
|
319
|
+
}
|
|
320
|
+
if (values['fail-on-console-error']) options.failOnConsoleError = true;
|
|
321
|
+
if (values['fail-on-failed-request']) options.failOnFailedRequest = true;
|
|
322
|
+
if (values['fail-on-duplicate-content']) options.failOnDuplicateContent = true;
|
|
323
|
+
if (values['fail-on-build-origin-metadata']) options.failOnBuildOriginMetadata = true;
|
|
324
|
+
if (values['fail-on-undetected']) options.failOnUndetected = true;
|
|
325
|
+
if (values['fail-on-verification-error']) options.failOnVerificationError = true;
|
|
220
326
|
if (values['no-block-third-party']) options.blockThirdParty = false;
|
|
221
327
|
if (values['block-third-party']) options.blockThirdParty = true;
|
|
222
328
|
if (values['allowed-hosts'] !== undefined)
|
|
@@ -227,7 +333,28 @@ function cliOptions({ values, positionals }) {
|
|
|
227
333
|
if (values.scroll) options.scrollToBottom = true;
|
|
228
334
|
if (values.flat) options.flatOutput = true;
|
|
229
335
|
if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
|
|
336
|
+
if (values['routes-file'] !== undefined) options.routesFile = values['routes-file'];
|
|
337
|
+
if (values['routes-from-sitemap'] !== undefined) {
|
|
338
|
+
options.routesFromSitemap = values['routes-from-sitemap'];
|
|
339
|
+
}
|
|
340
|
+
if (values['route-priority'] !== undefined) {
|
|
341
|
+
options.routePriority = splitList(values['route-priority']);
|
|
342
|
+
}
|
|
343
|
+
if (values.report !== undefined) options.reportPath = values.report;
|
|
344
|
+
if (values.audit !== undefined) options.audit = values.audit;
|
|
345
|
+
else if (values['no-audit']) options.audit = false;
|
|
346
|
+
if (values['fail-on-audit-error']) options.failOnAuditError = true;
|
|
347
|
+
if (values['audit-ignore-links'] !== undefined) {
|
|
348
|
+
options.auditIgnoreLinks = splitList(values['audit-ignore-links']);
|
|
349
|
+
}
|
|
350
|
+
if (values['preload-links']) options.preloadLinks = true;
|
|
351
|
+
if (values['image-sitemap']) options.imageSitemap = true;
|
|
352
|
+
if (values.framework !== undefined) options.framework = values.framework;
|
|
353
|
+
if (values.retries !== undefined) options.retries = Number(values.retries);
|
|
354
|
+
if (values['retry-delay'] !== undefined) options.retryDelay = Number(values['retry-delay']);
|
|
355
|
+
if (values['trace-on-error']) options.traceOnError = true;
|
|
230
356
|
if (values.destination !== undefined) options.destination = values.destination;
|
|
357
|
+
if (values['clean-destination']) options.cleanDestination = true;
|
|
231
358
|
if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
|
|
232
359
|
if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
|
|
233
360
|
const metadata = metadataOptions(values);
|
|
@@ -241,6 +368,7 @@ function cliOptions({ values, positionals }) {
|
|
|
241
368
|
if (values['minify-html']) options.minifyHtml = true;
|
|
242
369
|
if (values['minify-css']) options.minifyCss = true;
|
|
243
370
|
if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
|
|
371
|
+
if (values['preconnect-third-party']) options.preconnectThirdParty = true;
|
|
244
372
|
if (values['preload-images']) options.preloadImages = true;
|
|
245
373
|
if (values['preload-manifest']) options.preloadManifest = true;
|
|
246
374
|
if (values.sitemap) options.sitemap = true;
|
|
@@ -256,6 +384,8 @@ function cliOptions({ values, positionals }) {
|
|
|
256
384
|
if (values['remove-attributes'] !== undefined) {
|
|
257
385
|
options.removeAttributes = splitList(values['remove-attributes']);
|
|
258
386
|
}
|
|
387
|
+
if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
|
|
388
|
+
if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
|
|
259
389
|
if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
|
|
260
390
|
if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
|
|
261
391
|
if (values.url !== undefined) options.url = values.url;
|
|
@@ -270,161 +400,6 @@ function splitList(entries) {
|
|
|
270
400
|
return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
|
|
271
401
|
}
|
|
272
402
|
|
|
273
|
-
/**
|
|
274
|
-
* Setup wizard: asks for the handful of values that decide what gets prerendered and what the
|
|
275
|
-
* metadata says, then writes snappy.config.js. Flags cover every question, so
|
|
276
|
-
* `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
|
|
277
|
-
* terminal.
|
|
278
|
-
*/
|
|
279
|
-
async function runInit({ values }) {
|
|
280
|
-
const log = createLogger(values['log-level'] ?? 'info');
|
|
281
|
-
const cwd = process.cwd();
|
|
282
|
-
if (!values.force && fs.existsSync(CONFIG_FILE)) {
|
|
283
|
-
throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
|
|
284
|
-
}
|
|
285
|
-
if (!values.yes && !process.stdin.isTTY) {
|
|
286
|
-
throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const detected = await detectProjectDefaults(cwd);
|
|
290
|
-
const flags = cliOptions({ values, positionals: [] });
|
|
291
|
-
const defaults = defaultsFromFlags(detected, flags);
|
|
292
|
-
const answers = values.yes ? defaults : await askAnswers(defaults, log);
|
|
293
|
-
const cleaned = validatedAnswers(answers, log);
|
|
294
|
-
|
|
295
|
-
await writeConfigFile({
|
|
296
|
-
dir: cwd,
|
|
297
|
-
contents: renderConfigFile(cleaned),
|
|
298
|
-
force: values.force === true,
|
|
299
|
-
});
|
|
300
|
-
log.success(`Wrote ${CONFIG_FILE}`);
|
|
301
|
-
printNextSteps({ detected, log });
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Turns the flags the CLI already understands into the wizard's starting answers.
|
|
305
|
-
function defaultsFromFlags(detected, flags) {
|
|
306
|
-
return {
|
|
307
|
-
sourceDir: flags.sourceDir ?? detected.sourceDir,
|
|
308
|
-
base: flags.base ?? '/',
|
|
309
|
-
include: flags.include ?? ['/'],
|
|
310
|
-
crawl: flags.crawl ?? true,
|
|
311
|
-
siteUrl: flags.metadata?.siteUrl ?? '',
|
|
312
|
-
siteName: flags.metadata?.siteName ?? '',
|
|
313
|
-
titleTemplate: flags.metadata?.titleTemplate ?? '',
|
|
314
|
-
defaultImage: flags.metadata?.defaultImage ?? '',
|
|
315
|
-
trailingSlash: flags.metadata?.trailingSlash ?? '',
|
|
316
|
-
csp: flags.csp ?? 'off',
|
|
317
|
-
externalScripts: flags.externalScripts,
|
|
318
|
-
criticalCssPreload: flags.criticalCssPreload,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Asks every question in order, closing the terminal interface whatever the outcome.
|
|
323
|
-
async function askAnswers(defaults, log) {
|
|
324
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
325
|
-
try {
|
|
326
|
-
log.info('A few questions, then a config file with only the options you set.');
|
|
327
|
-
return {
|
|
328
|
-
sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
|
|
329
|
-
base: await ask(rl, 'Public base path', defaults.base),
|
|
330
|
-
include: parseRouteList(
|
|
331
|
-
await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
|
|
332
|
-
),
|
|
333
|
-
crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
|
|
334
|
-
siteUrl: await ask(
|
|
335
|
-
rl,
|
|
336
|
-
'Public site URL for canonical and og:url, blank to skip',
|
|
337
|
-
defaults.siteUrl,
|
|
338
|
-
),
|
|
339
|
-
siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
|
|
340
|
-
titleTemplate: await ask(
|
|
341
|
-
rl,
|
|
342
|
-
"Title template such as '%s | Example', blank to skip",
|
|
343
|
-
defaults.titleTemplate,
|
|
344
|
-
),
|
|
345
|
-
csp: (await askYesNo(
|
|
346
|
-
rl,
|
|
347
|
-
"Serve a strict CSP (script-src 'self', no inline scripts)?",
|
|
348
|
-
defaults.csp === 'strict',
|
|
349
|
-
))
|
|
350
|
-
? 'strict'
|
|
351
|
-
: 'off',
|
|
352
|
-
defaultImage: defaults.defaultImage,
|
|
353
|
-
trailingSlash: defaults.trailingSlash,
|
|
354
|
-
};
|
|
355
|
-
} finally {
|
|
356
|
-
rl.close();
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Asks one question, showing the value Enter will accept.
|
|
361
|
-
async function ask(rl, question, fallback) {
|
|
362
|
-
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
363
|
-
return answer.length > 0 ? answer : fallback;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Asks a yes/no question, treating Enter as the default.
|
|
367
|
-
async function askYesNo(rl, question, fallback) {
|
|
368
|
-
const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
|
|
369
|
-
.trim()
|
|
370
|
-
.toLowerCase();
|
|
371
|
-
if (answer.length === 0) return fallback;
|
|
372
|
-
return answer === 'y' || answer === 'yes';
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Drops values that would make a broken config, so a typo becomes a warning instead.
|
|
376
|
-
function validatedAnswers(answers, log) {
|
|
377
|
-
const cleaned = { ...answers };
|
|
378
|
-
if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
|
|
379
|
-
log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
|
|
380
|
-
cleaned.siteUrl = '';
|
|
381
|
-
}
|
|
382
|
-
if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
|
|
383
|
-
log.warn('ignoring the title template: it has no %s placeholder');
|
|
384
|
-
cleaned.titleTemplate = '';
|
|
385
|
-
}
|
|
386
|
-
cleaned.include = parseRouteList((cleaned.include ?? ['/']).join(','));
|
|
387
|
-
return cleaned;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Prints what to do next: install if needed, run it, and where the head layer goes.
|
|
391
|
-
function printNextSteps({ detected, log }) {
|
|
392
|
-
const run = runnerCommand(detected.runner, PACKAGE_NAME);
|
|
393
|
-
const install = installCommand(detected.runner);
|
|
394
|
-
log.info('');
|
|
395
|
-
log.info('Next steps:');
|
|
396
|
-
if (!detected.installed) log.info(` Install ${install}`);
|
|
397
|
-
log.info(` Dry run ${run} --dry-run`);
|
|
398
|
-
log.info(` Prerender ${run}`);
|
|
399
|
-
log.info('');
|
|
400
|
-
log.info('Per-route titles and meta tags come from the head layer:');
|
|
401
|
-
log.info(" import { Head } from 'snappy-prerender/head';");
|
|
402
|
-
log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
|
|
403
|
-
if (detected.usesVite) {
|
|
404
|
-
log.info('');
|
|
405
|
-
log.info('Or run it inside the build (vite.config.js):');
|
|
406
|
-
log.info(" import snappy from 'snappy-prerender';");
|
|
407
|
-
log.info(' plugins: [react(), snappy()],');
|
|
408
|
-
log.info(
|
|
409
|
-
` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Builds the command that runs a locally installed binary for the project's package runner.
|
|
415
|
-
function runnerCommand(runner, binary) {
|
|
416
|
-
if (runner === 'pnpm') return `pnpm exec ${binary}`;
|
|
417
|
-
if (runner === 'yarn') return `yarn ${binary}`;
|
|
418
|
-
return `npx ${binary}`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Builds the command that installs this package as a dev dependency.
|
|
422
|
-
function installCommand(runner) {
|
|
423
|
-
if (runner === 'pnpm') return `pnpm add -D ${PACKAGE_NAME}`;
|
|
424
|
-
if (runner === 'yarn') return `yarn add -D ${PACKAGE_NAME}`;
|
|
425
|
-
return `npm install --save-dev ${PACKAGE_NAME}`;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
403
|
// Collects the metadata flags, or null when none was passed so a config file value survives.
|
|
429
404
|
function metadataOptions(values) {
|
|
430
405
|
const metadata = {};
|
|
@@ -441,6 +416,7 @@ function metadataOptions(values) {
|
|
|
441
416
|
if (values['metadata-trailing-slash'] !== undefined) {
|
|
442
417
|
metadata.trailingSlash = values['metadata-trailing-slash'];
|
|
443
418
|
}
|
|
419
|
+
if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
|
|
444
420
|
return Object.keys(metadata).length > 0 ? metadata : null;
|
|
445
421
|
}
|
|
446
422
|
|
|
@@ -470,7 +446,22 @@ async function loadConfigFile(explicitPath) {
|
|
|
470
446
|
`Config file must export a plain options object or a function returning one: ${explicitPath}`,
|
|
471
447
|
);
|
|
472
448
|
}
|
|
473
|
-
return loaded;
|
|
449
|
+
return resolveSourcePaths(loaded, path.dirname(path.resolve(explicitPath)));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Resolves local route-source paths against the config file, not the process cwd, while
|
|
453
|
+
// leaving URLs and absolute paths alone.
|
|
454
|
+
function resolveSourcePaths(options, dir) {
|
|
455
|
+
const resolve = (value) => {
|
|
456
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
457
|
+
if (/^https?:\/\//i.test(value) || path.isAbsolute(value)) return value;
|
|
458
|
+
return path.resolve(dir, value);
|
|
459
|
+
};
|
|
460
|
+
return {
|
|
461
|
+
...options,
|
|
462
|
+
routesFile: resolve(options.routesFile),
|
|
463
|
+
routesFromSitemap: resolve(options.routesFromSitemap),
|
|
464
|
+
};
|
|
474
465
|
}
|
|
475
466
|
|
|
476
467
|
// Reads the package version without importing JSON, keeping Node 20 compatible.
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
locs: sitemap.locs,
|
|
73
|
+
};
|
|
74
|
+
log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const statuses = [];
|
|
79
|
+
for (const entry of sitemap.files) {
|
|
80
|
+
const written = await writeFileIfChanged({
|
|
81
|
+
dir: outputDir,
|
|
82
|
+
file: entry.name,
|
|
83
|
+
contents: entry.contents,
|
|
84
|
+
});
|
|
85
|
+
statuses.push(written.status);
|
|
86
|
+
report.files.push({ route: null, ...written });
|
|
87
|
+
}
|
|
88
|
+
report.sitemap = {
|
|
89
|
+
file: 'sitemap.xml',
|
|
90
|
+
routes: sitemap.routes,
|
|
91
|
+
robotsUpdated: patched !== null,
|
|
92
|
+
files: names,
|
|
93
|
+
locs: sitemap.locs,
|
|
94
|
+
};
|
|
95
|
+
log.info(
|
|
96
|
+
`${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (robots === null) {
|
|
100
|
+
log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (patched !== null) {
|
|
104
|
+
const written = await writeFileIfChanged({
|
|
105
|
+
dir: outputDir,
|
|
106
|
+
file: 'robots.txt',
|
|
107
|
+
contents: patched,
|
|
108
|
+
});
|
|
109
|
+
report.files.push({ route: null, ...written });
|
|
110
|
+
log.info('Added the sitemap location to robots.txt');
|
|
111
|
+
}
|
|
112
|
+
}
|