snappy-prerender 2.6.0 → 2.8.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 (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. package/src/head/tags.js +349 -30
package/src/cli.js CHANGED
@@ -1,17 +1,11 @@
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';
8
+ import { cliOptions } from './cli-options.js';
15
9
  import { createLogger } from './core/log.js';
16
10
  import { prerender } from './core/run.js';
17
11
 
@@ -43,8 +37,32 @@ Options:
43
37
  --browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
44
38
  --browser-download-version <build> Pin the Chrome for Testing build id
45
39
  --storage-state <file> Playwright storage state for authenticated routes
40
+ --freeze-time <iso|build> Fix Date/Date.now during render and verification
41
+ --timezone <iana> Browser timezone for render and verification
42
+ --random-seed <n> Deterministic Math.random for demo content
43
+ --allow-rerender <patterns> Hydration re-renders allowed for matching routes
44
+ --skip-hydration <patterns> Skip hydration verification for matching routes
45
+ --locale-mode <mode> path | single (requires --locales)
46
+ --locales <codes> Comma-separated locale codes, for example en,fr
47
+ --default-locale <code> Default locale code
48
+ --active-locale <code> Active locale for single mode
49
+ --locale-prefix <code=prefix> Locale path prefix, repeatable
50
+ --locale-url <code=url> Locale deployment origin, repeatable
51
+ --locale-storage-state <code=file> Locale storage state, repeatable
52
+ --no-hreflang Do not emit hreflang alternates
53
+ --seo-locale <code> Seed a cookie/localStorage SEO locale
54
+ --seo-locale-source <source> localStorage | cookie | both
55
+ --seo-locale-key <key> localStorage key for the SEO locale
56
+ --seo-locale-cookie <name> Cookie name for the SEO locale
57
+ --no-seo-locale-inject Do not write the locale bootstrap into output
58
+ --no-seo-locale-verify Do not seed the verification browser
59
+ --no-seo-locale-render Do not seed the render browser
60
+ --no-seo-locale-overwrite Only set the locale when it is missing
61
+ --seo-locale-navigator Also emulate navigator.language
62
+ --seo-locale-force-html-lang Override an existing html lang
46
63
  --no-verify Skip the hydration verification pass
47
64
  --verify-changed-only Skip verification for routes whose HTML did not change
65
+ --reuse-verification-context Reuse one browser context per verification worker
48
66
  --fail-on-hydration-error Fail the build when hydration errors are found
49
67
  --fail-on-rerender Fail when a route re-renders instead of hydrating
50
68
  --fail-on-page-error Fail when a page throws while it is being rendered
@@ -66,6 +84,20 @@ Options:
66
84
  --scroll Scroll through each page to trigger lazy content
67
85
  --flat Write about.html instead of about/index.html
68
86
  --not-found <route> Route emitted as 404.html (default: /404)
87
+ --routes-file <file> Read route seeds from a text/JSON file
88
+ --routes-from-sitemap <path-or-url> Seed routes from a local or remote sitemap
89
+ --route-priority <patterns> Patterns ranked first when crawling, comma separated
90
+ --report <file> Write a machine-readable run report (JSON)
91
+ --audit Check the written site for broken links and SEO issues (default: on)
92
+ --no-audit Skip the site audit
93
+ --fail-on-audit-error Fail when the audit finds an error
94
+ --audit-ignore-links <patterns> Link patterns excluded from the audit, comma separated
95
+ --preload-links Inject module, font and LCP image preload hints into each page
96
+ --image-sitemap List page images in the sitemap
97
+ --framework <name> auto | react | vue | svelte | preact (default: auto)
98
+ --retries <n> Retry a failed render this many times (default: 0)
99
+ --retry-delay <ms> Wait between render attempts (default: 500)
100
+ --trace-on-error Write a Playwright trace for the final failed attempt
69
101
  --destination <dir> Write output to another directory (default: in place)
70
102
  --clean-destination Empty the destination before copying the build output
71
103
  --save-as <format> html | png | jpeg (default: html)
@@ -73,6 +105,7 @@ Options:
73
105
  --metadata-site-url <url> Public site URL for canonical and og:url
74
106
  --metadata-site-name <name> Site name used for og:site_name
75
107
  --metadata-title-template <t> Title template, for example '%s | Example'
108
+ --metadata-default-title <t> Title used when a page sets no title
76
109
  --metadata-default-image <url> Share image for routes without one
77
110
  --metadata-trailing-slash <mode> preserve | always | never
78
111
  --metadata-base <path> Base path used for absolute metadata URLs
@@ -92,7 +125,7 @@ Options:
92
125
  --remove-styles Strip every style tag from the output
93
126
  --async-scripts Mark external scripts async
94
127
  --external-scripts Write injected state scripts as same-origin files, for a strict CSP
95
- --csp <mode> off | strict: strict makes the output work under script-src 'self'
128
+ --csp <mode> off | strict | hash: strict for script-src 'self', hash for inline hashes
96
129
  --no-remove-blobs Keep blob stylesheet links instead of dropping them
97
130
  --remove-attributes <names> Drop these attributes from the output, comma separated
98
131
  --no-strip-build-origin-metadata Keep tags that point at the prerender server
@@ -143,8 +176,32 @@ async function main() {
143
176
  'browser-download-hash': { type: 'string' },
144
177
  'browser-download-version': { type: 'string' },
145
178
  'storage-state': { type: 'string' },
179
+ 'freeze-time': { type: 'string' },
180
+ timezone: { type: 'string' },
181
+ 'random-seed': { type: 'string' },
182
+ 'allow-rerender': { type: 'string', multiple: true },
183
+ 'skip-hydration': { type: 'string', multiple: true },
184
+ 'locale-mode': { type: 'string' },
185
+ locales: { type: 'string' },
186
+ 'default-locale': { type: 'string' },
187
+ 'active-locale': { type: 'string' },
188
+ 'locale-prefix': { type: 'string', multiple: true },
189
+ 'locale-url': { type: 'string', multiple: true },
190
+ 'locale-storage-state': { type: 'string', multiple: true },
191
+ 'no-hreflang': { type: 'boolean' },
192
+ 'seo-locale': { type: 'string' },
193
+ 'seo-locale-source': { type: 'string' },
194
+ 'seo-locale-key': { type: 'string' },
195
+ 'seo-locale-cookie': { type: 'string' },
196
+ 'no-seo-locale-inject': { type: 'boolean' },
197
+ 'no-seo-locale-verify': { type: 'boolean' },
198
+ 'no-seo-locale-render': { type: 'boolean' },
199
+ 'no-seo-locale-overwrite': { type: 'boolean' },
200
+ 'seo-locale-navigator': { type: 'boolean' },
201
+ 'seo-locale-force-html-lang': { type: 'boolean' },
146
202
  'no-verify': { type: 'boolean' },
147
203
  'verify-changed-only': { type: 'boolean' },
204
+ 'reuse-verification-context': { type: 'boolean' },
148
205
  'fail-on-hydration-error': { type: 'boolean' },
149
206
  'fail-on-error': { type: 'boolean' },
150
207
  'fail-on-rerender': { type: 'boolean' },
@@ -165,6 +222,20 @@ async function main() {
165
222
  scroll: { type: 'boolean' },
166
223
  flat: { type: 'boolean' },
167
224
  'not-found': { type: 'string' },
225
+ 'routes-file': { type: 'string' },
226
+ 'routes-from-sitemap': { type: 'string' },
227
+ 'route-priority': { type: 'string', multiple: true },
228
+ report: { type: 'string' },
229
+ audit: { type: 'boolean' },
230
+ 'no-audit': { type: 'boolean' },
231
+ 'fail-on-audit-error': { type: 'boolean' },
232
+ 'audit-ignore-links': { type: 'string', multiple: true },
233
+ 'preload-links': { type: 'boolean' },
234
+ 'image-sitemap': { type: 'boolean' },
235
+ framework: { type: 'string' },
236
+ retries: { type: 'string' },
237
+ 'retry-delay': { type: 'string' },
238
+ 'trace-on-error': { type: 'boolean' },
168
239
  destination: { type: 'string' },
169
240
  'clean-destination': { type: 'boolean' },
170
241
  'save-as': { type: 'string' },
@@ -172,6 +243,7 @@ async function main() {
172
243
  'metadata-site-url': { type: 'string' },
173
244
  'metadata-site-name': { type: 'string' },
174
245
  'metadata-title-template': { type: 'string' },
246
+ 'metadata-default-title': { type: 'string' },
175
247
  'metadata-default-image': { type: 'string' },
176
248
  'metadata-trailing-slash': { type: 'string' },
177
249
  'metadata-base': { type: 'string' },
@@ -219,7 +291,12 @@ async function main() {
219
291
  return;
220
292
  }
221
293
  if (parsed.positionals[0] === 'init') {
222
- await runInit(parsed);
294
+ await runInit({
295
+ values: parsed.values,
296
+ flags: cliOptions({ values: parsed.values, positionals: [] }),
297
+ log: createLogger(parsed.values['log-level'] ?? 'info'),
298
+ version: readVersion(),
299
+ });
223
300
  return;
224
301
  }
225
302
 
@@ -228,6 +305,19 @@ async function main() {
228
305
  const options = { ...fileConfig, ...cli };
229
306
  // Metadata flags are partial: they refine a config file value instead of replacing it.
230
307
  if (cli.metadata) options.metadata = { ...(fileConfig.metadata ?? {}), ...cli.metadata };
308
+ if (cli.hydration) {
309
+ options.hydration = { ...(fileConfig.hydration ?? {}), ...cli.hydration };
310
+ }
311
+ if (cli.seoLocale) {
312
+ options.seoLocale = {
313
+ ...(fileConfig.seoLocale ?? {}),
314
+ ...cli.seoLocale,
315
+ cookie: {
316
+ ...(fileConfig.seoLocale?.cookie ?? {}),
317
+ ...(cli.seoLocale.cookie ?? {}),
318
+ },
319
+ };
320
+ }
231
321
  if (!options.sourceDir) options.sourceDir = 'dist';
232
322
 
233
323
  const log = createLogger(options.logLevel ?? 'info');
@@ -239,303 +329,6 @@ async function main() {
239
329
  * Translates parsed flags into prerender options, only including flags the user
240
330
  * actually passed so config-file values are never clobbered by defaults.
241
331
  */
242
- function cliOptions({ values, positionals }) {
243
- const options = {};
244
- if (positionals[0] !== undefined) options.sourceDir = positionals[0];
245
- if (values.source !== undefined) options.sourceDir = values.source;
246
- if (values.base !== undefined) options.base = values.base;
247
- if (values.include !== undefined) options.include = splitList(values.include);
248
- if (values.exclude !== undefined) options.exclude = splitList(values.exclude);
249
- if (values['no-crawl']) options.crawl = false;
250
- if (values['max-depth'] !== undefined) options.maxDepth = Number(values['max-depth']);
251
- if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
252
- if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
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;
275
- if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
276
- if (values.browser !== undefined) options.browser = values.browser;
277
- if (values['no-browser-download']) options.browserDownload = false;
278
- if (values['browser-download-hash'] !== undefined) {
279
- options.browserDownloadHash = values['browser-download-hash'];
280
- }
281
- if (values['browser-download-version'] !== undefined) {
282
- options.browserDownloadVersion = values['browser-download-version'];
283
- }
284
- if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
285
- if (values['no-verify']) options.verify = false;
286
- if (values['verify-changed-only']) options.verifyChangedOnly = true;
287
- if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
288
- if (values['fail-on-rerender']) options.failOnRerender = true;
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;
300
- if (values['no-block-third-party']) options.blockThirdParty = false;
301
- if (values['block-third-party']) options.blockThirdParty = true;
302
- if (values['allowed-hosts'] !== undefined)
303
- options.allowedHosts = splitList(values['allowed-hosts']);
304
- if (values['no-freeze-animations']) options.freezeAnimations = false;
305
- if (values['no-capture-runtime-styles']) options.captureRuntimeStyles = false;
306
- if (values['no-capture-form-state']) options.captureFormState = false;
307
- if (values.scroll) options.scrollToBottom = true;
308
- if (values.flat) options.flatOutput = true;
309
- if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
310
- if (values.destination !== undefined) options.destination = values.destination;
311
- if (values['clean-destination']) options.cleanDestination = true;
312
- if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
313
- if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
314
- const metadata = metadataOptions(values);
315
- if (metadata) options.metadata = metadata;
316
- if (values['inline-css'] !== undefined) options.inlineCss = values['inline-css'];
317
- if (values.csp !== undefined) options.csp = values.csp;
318
- if (values['critical-css-preload'] !== undefined) {
319
- options.criticalCssPreload =
320
- values['critical-css-preload'] === 'none' ? false : values['critical-css-preload'];
321
- }
322
- if (values['minify-html']) options.minifyHtml = true;
323
- if (values['minify-css']) options.minifyCss = true;
324
- if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
325
- if (values['preconnect-third-party']) options.preconnectThirdParty = true;
326
- if (values['preload-images']) options.preloadImages = true;
327
- if (values['preload-manifest']) options.preloadManifest = true;
328
- if (values.sitemap) options.sitemap = true;
329
- if (values['ignore-for-preload'] !== undefined) {
330
- options.ignoreForPreload = splitList(values['ignore-for-preload']);
331
- }
332
- if (values['cache-ajax-requests']) options.cacheAjaxRequests = true;
333
- if (values['remove-scripts']) options.removeScriptTags = true;
334
- if (values['remove-styles']) options.removeStyleTags = true;
335
- if (values['async-scripts']) options.asyncScriptTags = true;
336
- if (values['external-scripts']) options.externalScripts = true;
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;
343
- if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
344
- if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
345
- if (values.url !== undefined) options.url = values.url;
346
- if (values['serve-cmd'] !== undefined) options.serveCmd = values['serve-cmd'];
347
- if (values['dry-run']) options.dryRun = true;
348
- if (values['log-level'] !== undefined) options.logLevel = values['log-level'];
349
- return options;
350
- }
351
-
352
- // Splits comma separated flag values and drops empty entries.
353
- function splitList(entries) {
354
- return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
355
- }
356
-
357
- /**
358
- * Setup wizard: asks for the handful of values that decide what gets prerendered and what the
359
- * metadata says, then writes snappy.config.js. Flags cover every question, so
360
- * `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
361
- * terminal.
362
- */
363
- async function runInit({ values }) {
364
- const log = createLogger(values['log-level'] ?? 'info');
365
- const cwd = process.cwd();
366
- if (!values.force && fs.existsSync(CONFIG_FILE)) {
367
- throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
368
- }
369
- if (!values.yes && !process.stdin.isTTY) {
370
- throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
371
- }
372
-
373
- const detected = await detectProjectDefaults(cwd);
374
- const flags = cliOptions({ values, positionals: [] });
375
- const defaults = defaultsFromFlags(detected, flags);
376
- const answers = values.yes ? defaults : await askAnswers(defaults, log);
377
- const cleaned = validatedAnswers(answers, log);
378
-
379
- await writeConfigFile({
380
- dir: cwd,
381
- contents: renderConfigFile(cleaned),
382
- force: values.force === true,
383
- });
384
- log.success(`Wrote ${CONFIG_FILE}`);
385
- printNextSteps({ detected, log, tag: readVersion().includes('-') ? '@next' : '' });
386
- }
387
-
388
- // Turns the flags the CLI already understands into the wizard's starting answers.
389
- function defaultsFromFlags(detected, flags) {
390
- return {
391
- sourceDir: flags.sourceDir ?? detected.sourceDir,
392
- base: flags.base ?? detected.base ?? '/',
393
- include: flags.include ?? ['/'],
394
- crawl: flags.crawl ?? true,
395
- siteUrl: flags.metadata?.siteUrl ?? '',
396
- siteName: flags.metadata?.siteName ?? '',
397
- titleTemplate: flags.metadata?.titleTemplate ?? '',
398
- defaultImage: flags.metadata?.defaultImage ?? '',
399
- trailingSlash: flags.metadata?.trailingSlash ?? '',
400
- csp: flags.csp ?? 'off',
401
- externalScripts: flags.externalScripts,
402
- criticalCssPreload: flags.criticalCssPreload,
403
- };
404
- }
405
-
406
- // Asks every question in order, closing the terminal interface whatever the outcome.
407
- async function askAnswers(defaults, log) {
408
- const rl = createInterface({ input: process.stdin, output: process.stdout });
409
- try {
410
- log.info('A few questions, then a config file with only the options you set.');
411
- return {
412
- sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
413
- base: await ask(rl, 'Public base path', defaults.base),
414
- include: parseRouteList(
415
- await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
416
- ),
417
- crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
418
- siteUrl: await ask(
419
- rl,
420
- 'Public site URL for canonical and og:url, blank to skip',
421
- defaults.siteUrl,
422
- ),
423
- siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
424
- titleTemplate: await ask(
425
- rl,
426
- "Title template such as '%s | Example', blank to skip",
427
- defaults.titleTemplate,
428
- ),
429
- csp: (await askYesNo(
430
- rl,
431
- "Serve a strict CSP (script-src 'self', no inline scripts)?",
432
- defaults.csp === 'strict',
433
- ))
434
- ? 'strict'
435
- : 'off',
436
- defaultImage: defaults.defaultImage,
437
- trailingSlash: defaults.trailingSlash,
438
- };
439
- } finally {
440
- rl.close();
441
- }
442
- }
443
-
444
- // Asks one question, showing the value Enter will accept.
445
- async function ask(rl, question, fallback) {
446
- const answer = (await rl.question(`${question} (${fallback}): `)).trim();
447
- return answer.length > 0 ? answer : fallback;
448
- }
449
-
450
- // Asks a yes/no question, treating Enter as the default.
451
- async function askYesNo(rl, question, fallback) {
452
- const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
453
- .trim()
454
- .toLowerCase();
455
- if (answer.length === 0) return fallback;
456
- return answer === 'y' || answer === 'yes';
457
- }
458
-
459
- // Drops values that would make a broken config, so a typo becomes a warning instead.
460
- function validatedAnswers(answers, log) {
461
- const cleaned = { ...answers };
462
- if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
463
- log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
464
- cleaned.siteUrl = '';
465
- }
466
- if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
467
- log.warn('ignoring the title template: it has no %s placeholder');
468
- cleaned.titleTemplate = '';
469
- }
470
- cleaned.include = parseRouteList((cleaned.include ?? ['/']).join(','));
471
- return cleaned;
472
- }
473
-
474
- // Prints what to do next: install if needed, run it, and where the head layer goes.
475
- function printNextSteps({ detected, log, tag = '' }) {
476
- const run = runnerCommand(detected.runner, PACKAGE_NAME);
477
- const install = installCommand(detected.runner, tag);
478
- log.info('');
479
- log.info('Next steps:');
480
- if (!detected.installed) log.info(` Install ${install}`);
481
- log.info(` Dry run ${run} --dry-run`);
482
- log.info(` Prerender ${run}`);
483
- log.info('');
484
- log.info('Per-route titles and meta tags come from the head layer:');
485
- log.info(" import { Head } from 'snappy-prerender/head';");
486
- log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
487
- if (detected.usesVite) {
488
- log.info('');
489
- log.info('Or run it inside the build (vite.config.js):');
490
- log.info(" import snappy from 'snappy-prerender';");
491
- log.info(' plugins: [react(), snappy()],');
492
- log.info(
493
- ` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
494
- );
495
- }
496
- }
497
-
498
- // Builds the command that runs a locally installed binary for the project's package runner.
499
- function runnerCommand(runner, binary) {
500
- if (runner === 'pnpm') return `pnpm exec ${binary}`;
501
- if (runner === 'yarn') return `yarn ${binary}`;
502
- return `npx ${binary}`;
503
- }
504
-
505
- // Builds the command that installs this package as a dev dependency.
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}`;
511
- }
512
-
513
- // Collects the metadata flags, or null when none was passed so a config file value survives.
514
- function metadataOptions(values) {
515
- const metadata = {};
516
- if (values['metadata-site-url'] !== undefined) metadata.siteUrl = values['metadata-site-url'];
517
- if (values['metadata-site-name'] !== undefined) {
518
- metadata.siteName = values['metadata-site-name'];
519
- }
520
- if (values['metadata-title-template'] !== undefined) {
521
- metadata.titleTemplate = values['metadata-title-template'];
522
- }
523
- if (values['metadata-default-image'] !== undefined) {
524
- metadata.defaultImage = values['metadata-default-image'];
525
- }
526
- if (values['metadata-trailing-slash'] !== undefined) {
527
- metadata.trailingSlash = values['metadata-trailing-slash'];
528
- }
529
- if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
530
- return Object.keys(metadata).length > 0 ? metadata : null;
531
- }
532
-
533
- /**
534
- * Loads a JS config file. An explicitly requested file must exist; the implicit
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.
538
- */
539
332
  async function loadConfigFile(explicitPath) {
540
333
  if (explicitPath === undefined) {
541
334
  if (!fs.existsSync('snappy.config.js')) return {};
@@ -556,7 +349,33 @@ async function loadConfigFile(explicitPath) {
556
349
  `Config file must export a plain options object or a function returning one: ${explicitPath}`,
557
350
  );
558
351
  }
559
- return loaded;
352
+ return resolveSourcePaths(loaded, path.dirname(path.resolve(explicitPath)));
353
+ }
354
+
355
+ // Resolves local route-source paths against the config file, not the process cwd, while
356
+ // leaving URLs and absolute paths alone.
357
+ function resolveSourcePaths(options, dir) {
358
+ const resolve = (value) => {
359
+ if (typeof value !== 'string' || value.length === 0) return value;
360
+ if (/^https?:\/\//i.test(value) || path.isAbsolute(value)) return value;
361
+ return path.resolve(dir, value);
362
+ };
363
+ return {
364
+ ...options,
365
+ routesFile: resolve(options.routesFile),
366
+ routesFromSitemap: resolve(options.routesFromSitemap),
367
+ ...(options.locales?.list
368
+ ? {
369
+ locales: {
370
+ ...options.locales,
371
+ list: options.locales.list.map((locale) => ({
372
+ ...locale,
373
+ storageState: resolve(locale.storageState),
374
+ })),
375
+ },
376
+ }
377
+ : {}),
378
+ };
560
379
  }
561
380
 
562
381
  // Reads the package version without importing JSON, keeping Node 20 compatible.
@@ -69,6 +69,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
69
69
  routes: sitemap.routes,
70
70
  robotsUpdated: patched !== null,
71
71
  files: names,
72
+ locs: sitemap.locs,
72
73
  };
73
74
  log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
74
75
  return;
@@ -89,6 +90,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
89
90
  routes: sitemap.routes,
90
91
  robotsUpdated: patched !== null,
91
92
  files: names,
93
+ locs: sitemap.locs,
92
94
  };
93
95
  log.info(
94
96
  `${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
@@ -0,0 +1,114 @@
1
+ import { auditLinks, auditOrphans } from './links.js';
2
+ import { auditMetadata } from './metadata.js';
3
+ import { auditSitemap } from './sitemap.js';
4
+
5
+ /**
6
+ * Audits the written site and returns report-only findings. Errors are the things a
7
+ * static site should not ship (broken links, sitemap disagreement, duplicate titles);
8
+ * warnings cover metadata that is useful but legitimately optional.
9
+ */
10
+ export async function auditRun({ report, config, outputDir }) {
11
+ const errors = [];
12
+ const warnings = [];
13
+ const add = (severity, rule, message, details = {}) => {
14
+ const finding = { rule, severity, message, ...details };
15
+ if (severity === 'error') errors.push(finding);
16
+ else warnings.push(finding);
17
+ };
18
+
19
+ const pages = report.routeDetails.filter((detail) => detail.file?.endsWith('.html'));
20
+ auditRouteHealth({ report, add });
21
+ auditMetadata({ pages, config, add });
22
+ auditSitemap({ pages, config, report, add });
23
+ const linkAudit = await auditLinks({ report, config, outputDir, add });
24
+ auditOrphans({ pages, report, add });
25
+ auditCspHygiene({ pages, config, report, add });
26
+ removeDuplicateFindings({ errors, warnings });
27
+
28
+ return {
29
+ errors,
30
+ warnings,
31
+ linksChecked: linkAudit.checked,
32
+ };
33
+ }
34
+
35
+ // Mirrors route-level failures into the audit so failOnAuditError covers route health
36
+ // without requiring callers to inspect a second collection.
37
+ function auditRouteHealth({ report, add }) {
38
+ for (const error of report.errors ?? []) {
39
+ add('error', 'route-error', error.message, { route: error.route ?? undefined });
40
+ }
41
+ if (report.truncated) {
42
+ add(
43
+ 'error',
44
+ 'route-cap',
45
+ `maxRoutes (${report.truncated.limit}) stopped the crawl after ${report.truncated.routes} route(s)`,
46
+ { droppedRoutes: report.truncated.droppedRoutes ?? [] },
47
+ );
48
+ }
49
+ for (const route of report.emptyPages ?? []) {
50
+ add('error', 'empty-page', 'rendered an empty page', { route });
51
+ }
52
+ for (const group of report.duplicatePages ?? []) {
53
+ for (const route of group.routes) {
54
+ const others = group.routes.filter((other) => other !== route).join(', ');
55
+ add('error', 'duplicate-output', `identical output to ${others}`, { route });
56
+ }
57
+ }
58
+ }
59
+
60
+ // Inline style attributes cannot be covered by a CSP hash, and browser console errors
61
+ // reveal assets the generated policy blocked.
62
+ function auditCspHygiene({ pages, config, report, add }) {
63
+ if (config.csp !== 'hash') return;
64
+ for (const route of report.cspSkipped ?? []) {
65
+ add(
66
+ 'warning',
67
+ 'existing-csp-meta',
68
+ `${route} already has a CSP meta, so no hashes were injected`,
69
+ { route },
70
+ );
71
+ }
72
+ const violations = new Map();
73
+ const collect = (route, message) => {
74
+ if (!/content security policy|violates the following/i.test(message)) return;
75
+ const messages = violations.get(route) ?? new Set();
76
+ messages.add(message);
77
+ violations.set(route, messages);
78
+ };
79
+ for (const { route, message } of report.consoleErrors ?? []) collect(route, message);
80
+ for (const page of pages) {
81
+ for (const message of page.consoleErrors ?? []) collect(page.route, message);
82
+ }
83
+ for (const [route, messages] of violations) {
84
+ add(
85
+ 'warning',
86
+ 'csp-console-violation',
87
+ `${route} logged ${messages.size} Content-Security-Policy violation(s)`,
88
+ { route },
89
+ );
90
+ }
91
+ for (const page of pages) {
92
+ if (page.meta?.inlineStyleAttributes > 0) {
93
+ add(
94
+ 'warning',
95
+ 'inline-style-attributes',
96
+ `${page.route} has ${page.meta.inlineStyleAttributes} inline style attribute(s); CSP hashes cannot cover them`,
97
+ { route: page.route },
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ // A finding can be produced by more than one check; keep the first copy.
104
+ function removeDuplicateFindings({ errors, warnings }) {
105
+ for (const list of [errors, warnings]) {
106
+ const seen = new Set();
107
+ for (let index = list.length - 1; index >= 0; index -= 1) {
108
+ const finding = list[index];
109
+ const key = `${finding.rule}|${finding.route ?? ''}|${finding.message}`;
110
+ if (seen.has(key)) list.splice(index, 1);
111
+ else seen.add(key);
112
+ }
113
+ }
114
+ }