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.
Files changed (48) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +149 -107
  3. package/docs/limitations.md +58 -0
  4. package/docs/react-snap-migration.md +45 -0
  5. package/docs/report.md +102 -0
  6. package/package.json +9 -5
  7. package/src/cli-init.js +205 -0
  8. package/src/cli.js +159 -168
  9. package/src/core/artifacts.js +112 -0
  10. package/src/core/audit/index.js +114 -0
  11. package/src/core/audit/links.js +63 -0
  12. package/src/core/audit/metadata.js +88 -0
  13. package/src/core/audit/sitemap.js +39 -0
  14. package/src/core/blocking.js +53 -1
  15. package/src/core/browser.js +109 -39
  16. package/src/core/build-origin.js +19 -0
  17. package/src/core/config.js +256 -6
  18. package/src/core/crawl.js +300 -0
  19. package/src/core/csp.js +127 -0
  20. package/src/core/css-url.js +34 -0
  21. package/src/core/destination.js +28 -3
  22. package/src/core/frameworks.js +94 -0
  23. package/src/core/hydration-diff.js +93 -0
  24. package/src/core/init.js +187 -31
  25. package/src/core/inline-css.js +34 -13
  26. package/src/core/metadata-origin.js +11 -2
  27. package/src/core/normalize.js +59 -2
  28. package/src/core/output.js +32 -9
  29. package/src/core/page-content.js +13 -0
  30. package/src/core/page-meta.js +108 -0
  31. package/src/core/preload-links.js +120 -0
  32. package/src/core/preload-manifest.js +61 -7
  33. package/src/core/prune.js +65 -0
  34. package/src/core/render.js +147 -11
  35. package/src/core/report.js +108 -0
  36. package/src/core/resources.js +6 -1
  37. package/src/core/route-config.js +17 -0
  38. package/src/core/route-sources.js +209 -0
  39. package/src/core/routes.js +9 -0
  40. package/src/core/run.js +186 -358
  41. package/src/core/server.js +30 -4
  42. package/src/core/sitemap.js +128 -20
  43. package/src/core/url-policy.js +32 -0
  44. package/src/core/verify.js +103 -49
  45. package/src/core/write.js +363 -0
  46. package/src/head/index.js +2 -2
  47. package/src/head/manager.js +54 -13
  48. 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 makes the output work under script-src 'self'
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(parsed);
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
+ }