snappy-prerender 2.7.0 → 2.8.1

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Translates parsed flags into prerender options, only including flags the user actually
3
+ * passed so config-file values are never clobbered by defaults.
4
+ * @param {{ values: object, positionals: string[] }} input
5
+ * @returns {object}
6
+ */
7
+ export function cliOptions({ values, positionals }) {
8
+ const options = {};
9
+ if (positionals[0] !== undefined) options.sourceDir = positionals[0];
10
+ if (values.source !== undefined) options.sourceDir = values.source;
11
+ if (values.base !== undefined) options.base = values.base;
12
+ if (values.include !== undefined) options.include = splitList(values.include);
13
+ if (values.exclude !== undefined) options.exclude = splitList(values.exclude);
14
+ if (values['no-crawl']) options.crawl = false;
15
+ if (values['max-depth'] !== undefined) options.maxDepth = Number(values['max-depth']);
16
+ if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
17
+ if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
18
+ if (values.timeout !== undefined) options.timeout = Number(values.timeout);
19
+ if (values['quiet-period'] !== undefined) options.quietPeriod = Number(values['quiet-period']);
20
+ if (values['scroll-step-delay'] !== undefined) {
21
+ options.scrollStepDelay = Number(values['scroll-step-delay']);
22
+ }
23
+ if (values['max-cached-bytes'] !== undefined) {
24
+ options.maxCachedBytes = Number(values['max-cached-bytes']);
25
+ }
26
+ if (values['serve-cmd-timeout'] !== undefined) {
27
+ options.serveCmdTimeout = Number(values['serve-cmd-timeout']);
28
+ }
29
+ if (values['shutdown-timeout'] !== undefined) {
30
+ options.shutdownTimeout = Number(values['shutdown-timeout']);
31
+ }
32
+ if (values.viewport !== undefined) {
33
+ const match = /^(\d+)x(\d+)$/i.exec(values.viewport);
34
+ if (!match) throw new Error('viewport must look like 1280x720');
35
+ options.viewport = { width: Number(match[1]), height: Number(match[2]) };
36
+ }
37
+ if (values['ready-selector'] !== undefined) options.readySelector = values['ready-selector'];
38
+ if (values['ready-flag'] !== undefined) options.readyFlag = values['ready-flag'];
39
+ if (values.headless !== undefined) options.headless = values.headless;
40
+ if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
41
+ if (values.browser !== undefined) options.browser = values.browser;
42
+ if (values['no-browser-download']) options.browserDownload = false;
43
+ if (values['browser-download-hash'] !== undefined) {
44
+ options.browserDownloadHash = values['browser-download-hash'];
45
+ }
46
+ if (values['browser-download-version'] !== undefined) {
47
+ options.browserDownloadVersion = values['browser-download-version'];
48
+ }
49
+ if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
50
+ if (values['freeze-time'] !== undefined) {
51
+ options.freezeTime = values['freeze-time'] === 'build' ? true : values['freeze-time'];
52
+ }
53
+ if (values.timezone !== undefined) options.timezone = values.timezone;
54
+ if (values['random-seed'] !== undefined) options.randomSeed = Number(values['random-seed']);
55
+ if (values['allow-rerender'] !== undefined || values['skip-hydration'] !== undefined) {
56
+ options.hydration = {
57
+ ...(values['allow-rerender'] !== undefined
58
+ ? { allowRerender: splitList(values['allow-rerender']) }
59
+ : {}),
60
+ ...(values['skip-hydration'] !== undefined
61
+ ? { skip: splitList(values['skip-hydration']) }
62
+ : {}),
63
+ };
64
+ }
65
+ const localeOptions = localeCliOptions(values);
66
+ if (localeOptions) options.locales = localeOptions;
67
+ const seoLocale = seoLocaleCliOptions(values);
68
+ if (seoLocale) options.seoLocale = seoLocale;
69
+ if (values['no-verify']) options.verify = false;
70
+ if (values['verify-changed-only']) options.verifyChangedOnly = true;
71
+ if (values['reuse-verification-context']) options.reuseVerificationContext = true;
72
+ if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
73
+ if (values['fail-on-rerender']) options.failOnRerender = true;
74
+ if (values['fail-on-page-error']) options.failOnPageError = true;
75
+ if (values['fail-on-error'] !== undefined) options.failOnError = values['fail-on-error'];
76
+ if (values['fail-on-empty-page'] !== undefined) {
77
+ options.failOnEmptyPage = values['fail-on-empty-page'];
78
+ }
79
+ if (values['fail-on-console-error']) options.failOnConsoleError = true;
80
+ if (values['fail-on-failed-request']) options.failOnFailedRequest = true;
81
+ if (values['fail-on-duplicate-content']) options.failOnDuplicateContent = true;
82
+ if (values['fail-on-build-origin-metadata']) options.failOnBuildOriginMetadata = true;
83
+ if (values['fail-on-undetected']) options.failOnUndetected = true;
84
+ if (values['fail-on-verification-error']) options.failOnVerificationError = true;
85
+ if (values['no-block-third-party']) options.blockThirdParty = false;
86
+ if (values['block-third-party']) options.blockThirdParty = true;
87
+ if (values['allowed-hosts'] !== undefined)
88
+ options.allowedHosts = splitList(values['allowed-hosts']);
89
+ if (values['no-freeze-animations']) options.freezeAnimations = false;
90
+ if (values['no-capture-runtime-styles']) options.captureRuntimeStyles = false;
91
+ if (values['no-capture-form-state']) options.captureFormState = false;
92
+ if (values.scroll) options.scrollToBottom = true;
93
+ if (values.flat) options.flatOutput = true;
94
+ if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
95
+ if (values['routes-file'] !== undefined) options.routesFile = values['routes-file'];
96
+ if (values['routes-from-sitemap'] !== undefined) {
97
+ options.routesFromSitemap = values['routes-from-sitemap'];
98
+ }
99
+ if (values['route-priority'] !== undefined) {
100
+ options.routePriority = splitList(values['route-priority']);
101
+ }
102
+ if (values.report !== undefined) options.reportPath = values.report;
103
+ if (values.audit !== undefined) options.audit = values.audit;
104
+ else if (values['no-audit']) options.audit = false;
105
+ if (values['fail-on-audit-error']) options.failOnAuditError = true;
106
+ if (values['audit-ignore-links'] !== undefined) {
107
+ options.auditIgnoreLinks = splitList(values['audit-ignore-links']);
108
+ }
109
+ if (values['preload-links']) options.preloadLinks = true;
110
+ if (values['image-sitemap']) options.imageSitemap = true;
111
+ if (values.framework !== undefined) options.framework = values.framework;
112
+ if (values.retries !== undefined) options.retries = Number(values.retries);
113
+ if (values['retry-delay'] !== undefined) options.retryDelay = Number(values['retry-delay']);
114
+ if (values['trace-on-error']) options.traceOnError = true;
115
+ if (values.destination !== undefined) options.destination = values.destination;
116
+ if (values['clean-destination']) options.cleanDestination = true;
117
+ if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
118
+ if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
119
+ const metadata = metadataOptions(values);
120
+ if (metadata) options.metadata = metadata;
121
+ if (values['inline-css'] !== undefined) options.inlineCss = values['inline-css'];
122
+ if (values.csp !== undefined) options.csp = values.csp;
123
+ if (values['critical-css-preload'] !== undefined) {
124
+ options.criticalCssPreload =
125
+ values['critical-css-preload'] === 'none' ? false : values['critical-css-preload'];
126
+ }
127
+ if (values['minify-html']) options.minifyHtml = true;
128
+ if (values['minify-css']) options.minifyCss = true;
129
+ if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
130
+ if (values['preconnect-third-party']) options.preconnectThirdParty = true;
131
+ if (values['preload-images']) options.preloadImages = true;
132
+ if (values['preload-manifest']) options.preloadManifest = true;
133
+ if (values.sitemap) options.sitemap = true;
134
+ if (values['ignore-for-preload'] !== undefined) {
135
+ options.ignoreForPreload = splitList(values['ignore-for-preload']);
136
+ }
137
+ if (values['cache-ajax-requests']) options.cacheAjaxRequests = true;
138
+ if (values['remove-scripts']) options.removeScriptTags = true;
139
+ if (values['remove-styles']) options.removeStyleTags = true;
140
+ if (values['async-scripts']) options.asyncScriptTags = true;
141
+ if (values['external-scripts']) options.externalScripts = true;
142
+ if (values['no-remove-blobs']) options.removeBlobs = false;
143
+ if (values['remove-attributes'] !== undefined) {
144
+ options.removeAttributes = splitList(values['remove-attributes']);
145
+ }
146
+ if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
147
+ if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
148
+ if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
149
+ if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
150
+ if (values.url !== undefined) options.url = values.url;
151
+ if (values['serve-cmd'] !== undefined) options.serveCmd = values['serve-cmd'];
152
+ if (values['dry-run']) options.dryRun = true;
153
+ if (values['log-level'] !== undefined) options.logLevel = values['log-level'];
154
+ return options;
155
+ }
156
+
157
+ // Splits comma separated flag values and drops empty entries.
158
+ function splitList(entries) {
159
+ return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
160
+ }
161
+
162
+ // Parses repeatable code=value flags used by the locale matrix.
163
+ function parsePairs(entries) {
164
+ const map = {};
165
+ for (const entry of splitList(entries)) {
166
+ const index = entry.indexOf('=');
167
+ if (index <= 0) throw new Error('locale options must look like code=value');
168
+ map[entry.slice(0, index)] = entry.slice(index + 1);
169
+ }
170
+ return map;
171
+ }
172
+
173
+ // Builds a locale matrix from the simple CLI flags. Full per-locale cookies, storage and
174
+ // translated slugs stay in a config file.
175
+ function localeCliOptions(values) {
176
+ const enabled =
177
+ values.locales !== undefined ||
178
+ values['locale-mode'] !== undefined ||
179
+ values['active-locale'] !== undefined;
180
+ if (!enabled) return null;
181
+ if (values.locales === undefined) throw new Error('--locales is required with locale flags');
182
+ const codes = splitList([values.locales]);
183
+ const defaultCode = values['default-locale'] ?? values['active-locale'] ?? codes[0];
184
+ const mode = values['locale-mode'] ?? (values['active-locale'] ? 'single' : 'path');
185
+ const prefixes = parsePairs(values['locale-prefix'] ?? []);
186
+ const urls = parsePairs(values['locale-url'] ?? []);
187
+ const states = parsePairs(values['locale-storage-state'] ?? []);
188
+ return {
189
+ mode,
190
+ default: defaultCode,
191
+ ...(values['active-locale'] ? { active: values['active-locale'] } : {}),
192
+ hreflang: values['no-hreflang'] ? false : undefined,
193
+ list: codes.map((code) => ({
194
+ code,
195
+ prefix: prefixes[code],
196
+ url: urls[code],
197
+ storageState: states[code],
198
+ })),
199
+ };
200
+ }
201
+
202
+ // Builds the optional SEO locale recipe from the CLI flags.
203
+ function seoLocaleCliOptions(values) {
204
+ const enabled =
205
+ values['seo-locale'] !== undefined ||
206
+ values['seo-locale-source'] !== undefined ||
207
+ values['seo-locale-key'] !== undefined ||
208
+ values['seo-locale-cookie'] !== undefined ||
209
+ values['no-seo-locale-inject'] ||
210
+ values['no-seo-locale-verify'] ||
211
+ values['no-seo-locale-render'] ||
212
+ values['no-seo-locale-overwrite'] ||
213
+ values['seo-locale-navigator'] ||
214
+ values['seo-locale-force-html-lang'];
215
+ if (!enabled) return null;
216
+ return {
217
+ ...(values['seo-locale'] ? { code: values['seo-locale'] } : {}),
218
+ ...(values['seo-locale-source'] ? { source: values['seo-locale-source'] } : {}),
219
+ ...(values['seo-locale-key'] ? { localStorageKey: values['seo-locale-key'] } : {}),
220
+ ...(values['seo-locale-cookie'] ? { cookie: { name: values['seo-locale-cookie'] } } : {}),
221
+ ...(values['no-seo-locale-inject'] ? { inject: false } : {}),
222
+ ...(values['no-seo-locale-verify'] ? { seedVerification: false } : {}),
223
+ ...(values['no-seo-locale-render'] ? { seedRender: false } : {}),
224
+ ...(values['no-seo-locale-overwrite'] ? { overwrite: false } : {}),
225
+ ...(values['seo-locale-navigator'] ? { navigator: true } : {}),
226
+ ...(values['seo-locale-force-html-lang'] ? { forceHtmlLang: true } : {}),
227
+ };
228
+ }
229
+
230
+ // Collects the metadata flags, or null when none was passed so a config file value survives.
231
+ function metadataOptions(values) {
232
+ const metadata = {};
233
+ if (values['metadata-site-url'] !== undefined) metadata.siteUrl = values['metadata-site-url'];
234
+ if (values['metadata-site-name'] !== undefined) {
235
+ metadata.siteName = values['metadata-site-name'];
236
+ }
237
+ if (values['metadata-title-template'] !== undefined) {
238
+ metadata.titleTemplate = values['metadata-title-template'];
239
+ }
240
+ if (values['metadata-default-title'] !== undefined) {
241
+ metadata.defaultTitle = values['metadata-default-title'];
242
+ }
243
+ if (values['metadata-default-image'] !== undefined) {
244
+ metadata.defaultImage = values['metadata-default-image'];
245
+ }
246
+ if (values['metadata-trailing-slash'] !== undefined) {
247
+ metadata.trailingSlash = values['metadata-trailing-slash'];
248
+ }
249
+ if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
250
+ return Object.keys(metadata).length > 0 ? metadata : null;
251
+ }
252
+
253
+ /**
254
+ * Loads a JS config file. An explicitly requested file must exist; the implicit
255
+ * snappy.config.js is optional. The default export is either a plain options object or an
256
+ * async function that returns one, which is how routes are computed from a CMS, a sitemap or
257
+ * a file listing without a separate prebuild script.
258
+ */
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import process from 'node:process';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { runInit } from './cli-init.js';
8
+ import { cliOptions } from './cli-options.js';
8
9
  import { createLogger } from './core/log.js';
9
10
  import { prerender } from './core/run.js';
10
11
 
@@ -36,8 +37,32 @@ Options:
36
37
  --browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
37
38
  --browser-download-version <build> Pin the Chrome for Testing build id
38
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
39
63
  --no-verify Skip the hydration verification pass
40
64
  --verify-changed-only Skip verification for routes whose HTML did not change
65
+ --reuse-verification-context Reuse one browser context per verification worker
41
66
  --fail-on-hydration-error Fail the build when hydration errors are found
42
67
  --fail-on-rerender Fail when a route re-renders instead of hydrating
43
68
  --fail-on-page-error Fail when a page throws while it is being rendered
@@ -80,6 +105,7 @@ Options:
80
105
  --metadata-site-url <url> Public site URL for canonical and og:url
81
106
  --metadata-site-name <name> Site name used for og:site_name
82
107
  --metadata-title-template <t> Title template, for example '%s | Example'
108
+ --metadata-default-title <t> Title used when a page sets no title
83
109
  --metadata-default-image <url> Share image for routes without one
84
110
  --metadata-trailing-slash <mode> preserve | always | never
85
111
  --metadata-base <path> Base path used for absolute metadata URLs
@@ -150,8 +176,32 @@ async function main() {
150
176
  'browser-download-hash': { type: 'string' },
151
177
  'browser-download-version': { type: 'string' },
152
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' },
153
202
  'no-verify': { type: 'boolean' },
154
203
  'verify-changed-only': { type: 'boolean' },
204
+ 'reuse-verification-context': { type: 'boolean' },
155
205
  'fail-on-hydration-error': { type: 'boolean' },
156
206
  'fail-on-error': { type: 'boolean' },
157
207
  'fail-on-rerender': { type: 'boolean' },
@@ -193,6 +243,7 @@ async function main() {
193
243
  'metadata-site-url': { type: 'string' },
194
244
  'metadata-site-name': { type: 'string' },
195
245
  'metadata-title-template': { type: 'string' },
246
+ 'metadata-default-title': { type: 'string' },
196
247
  'metadata-default-image': { type: 'string' },
197
248
  'metadata-trailing-slash': { type: 'string' },
198
249
  'metadata-base': { type: 'string' },
@@ -254,6 +305,19 @@ async function main() {
254
305
  const options = { ...fileConfig, ...cli };
255
306
  // Metadata flags are partial: they refine a config file value instead of replacing it.
256
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
+ }
257
321
  if (!options.sourceDir) options.sourceDir = 'dist';
258
322
 
259
323
  const log = createLogger(options.logLevel ?? 'info');
@@ -265,167 +329,6 @@ async function main() {
265
329
  * Translates parsed flags into prerender options, only including flags the user
266
330
  * actually passed so config-file values are never clobbered by defaults.
267
331
  */
268
- function cliOptions({ values, positionals }) {
269
- const options = {};
270
- if (positionals[0] !== undefined) options.sourceDir = positionals[0];
271
- if (values.source !== undefined) options.sourceDir = values.source;
272
- if (values.base !== undefined) options.base = values.base;
273
- if (values.include !== undefined) options.include = splitList(values.include);
274
- if (values.exclude !== undefined) options.exclude = splitList(values.exclude);
275
- if (values['no-crawl']) options.crawl = false;
276
- if (values['max-depth'] !== undefined) options.maxDepth = Number(values['max-depth']);
277
- if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
278
- if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
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;
301
- if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
302
- if (values.browser !== undefined) options.browser = values.browser;
303
- if (values['no-browser-download']) options.browserDownload = false;
304
- if (values['browser-download-hash'] !== undefined) {
305
- options.browserDownloadHash = values['browser-download-hash'];
306
- }
307
- if (values['browser-download-version'] !== undefined) {
308
- options.browserDownloadVersion = values['browser-download-version'];
309
- }
310
- if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
311
- if (values['no-verify']) options.verify = false;
312
- if (values['verify-changed-only']) options.verifyChangedOnly = true;
313
- if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
314
- if (values['fail-on-rerender']) options.failOnRerender = true;
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;
326
- if (values['no-block-third-party']) options.blockThirdParty = false;
327
- if (values['block-third-party']) options.blockThirdParty = true;
328
- if (values['allowed-hosts'] !== undefined)
329
- options.allowedHosts = splitList(values['allowed-hosts']);
330
- if (values['no-freeze-animations']) options.freezeAnimations = false;
331
- if (values['no-capture-runtime-styles']) options.captureRuntimeStyles = false;
332
- if (values['no-capture-form-state']) options.captureFormState = false;
333
- if (values.scroll) options.scrollToBottom = true;
334
- if (values.flat) options.flatOutput = true;
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;
356
- if (values.destination !== undefined) options.destination = values.destination;
357
- if (values['clean-destination']) options.cleanDestination = true;
358
- if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
359
- if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
360
- const metadata = metadataOptions(values);
361
- if (metadata) options.metadata = metadata;
362
- if (values['inline-css'] !== undefined) options.inlineCss = values['inline-css'];
363
- if (values.csp !== undefined) options.csp = values.csp;
364
- if (values['critical-css-preload'] !== undefined) {
365
- options.criticalCssPreload =
366
- values['critical-css-preload'] === 'none' ? false : values['critical-css-preload'];
367
- }
368
- if (values['minify-html']) options.minifyHtml = true;
369
- if (values['minify-css']) options.minifyCss = true;
370
- if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
371
- if (values['preconnect-third-party']) options.preconnectThirdParty = true;
372
- if (values['preload-images']) options.preloadImages = true;
373
- if (values['preload-manifest']) options.preloadManifest = true;
374
- if (values.sitemap) options.sitemap = true;
375
- if (values['ignore-for-preload'] !== undefined) {
376
- options.ignoreForPreload = splitList(values['ignore-for-preload']);
377
- }
378
- if (values['cache-ajax-requests']) options.cacheAjaxRequests = true;
379
- if (values['remove-scripts']) options.removeScriptTags = true;
380
- if (values['remove-styles']) options.removeStyleTags = true;
381
- if (values['async-scripts']) options.asyncScriptTags = true;
382
- if (values['external-scripts']) options.externalScripts = true;
383
- if (values['no-remove-blobs']) options.removeBlobs = false;
384
- if (values['remove-attributes'] !== undefined) {
385
- options.removeAttributes = splitList(values['remove-attributes']);
386
- }
387
- if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
388
- if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
389
- if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
390
- if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
391
- if (values.url !== undefined) options.url = values.url;
392
- if (values['serve-cmd'] !== undefined) options.serveCmd = values['serve-cmd'];
393
- if (values['dry-run']) options.dryRun = true;
394
- if (values['log-level'] !== undefined) options.logLevel = values['log-level'];
395
- return options;
396
- }
397
-
398
- // Splits comma separated flag values and drops empty entries.
399
- function splitList(entries) {
400
- return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
401
- }
402
-
403
- // Collects the metadata flags, or null when none was passed so a config file value survives.
404
- function metadataOptions(values) {
405
- const metadata = {};
406
- if (values['metadata-site-url'] !== undefined) metadata.siteUrl = values['metadata-site-url'];
407
- if (values['metadata-site-name'] !== undefined) {
408
- metadata.siteName = values['metadata-site-name'];
409
- }
410
- if (values['metadata-title-template'] !== undefined) {
411
- metadata.titleTemplate = values['metadata-title-template'];
412
- }
413
- if (values['metadata-default-image'] !== undefined) {
414
- metadata.defaultImage = values['metadata-default-image'];
415
- }
416
- if (values['metadata-trailing-slash'] !== undefined) {
417
- metadata.trailingSlash = values['metadata-trailing-slash'];
418
- }
419
- if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
420
- return Object.keys(metadata).length > 0 ? metadata : null;
421
- }
422
-
423
- /**
424
- * Loads a JS config file. An explicitly requested file must exist; the implicit
425
- * snappy.config.js is optional. The default export is either a plain options object or an
426
- * async function that returns one, which is how routes are computed from a CMS, a sitemap or
427
- * a file listing without a separate prebuild script.
428
- */
429
332
  async function loadConfigFile(explicitPath) {
430
333
  if (explicitPath === undefined) {
431
334
  if (!fs.existsSync('snappy.config.js')) return {};
@@ -461,6 +364,17 @@ function resolveSourcePaths(options, dir) {
461
364
  ...options,
462
365
  routesFile: resolve(options.routesFile),
463
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
+ : {}),
464
378
  };
465
379
  }
466
380
 
@@ -2,6 +2,9 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { routeToFile } from '../output.js';
4
4
  import { matchesPattern, normaliseRoute } from '../routes.js';
5
+ import { mapLimit } from '../scheduler.js';
6
+
7
+ const LINK_CHECK_CONCURRENCY = 16;
5
8
 
6
9
  /**
7
10
  * Audits every internal link the rendered pages emitted and warns about indexable pages
@@ -13,6 +16,7 @@ export async function auditLinks({ report, config, outputDir, add }) {
13
16
  const cache = new Map();
14
17
  const seenPairs = new Set();
15
18
  let checkedLinks = 0;
19
+ const pending = [];
16
20
 
17
21
  const exists = async (target) => {
18
22
  if (cache.has(target)) return cache.get(target);
@@ -35,7 +39,14 @@ export async function auditLinks({ report, config, outputDir, add }) {
35
39
  const file = routeToFile(route, config);
36
40
  if (files.has(file)) continue;
37
41
  const asset = route.startsWith('/') ? route.slice(1) : route;
38
- if (asset && (await exists(asset))) continue;
42
+ pending.push({ link, asset });
43
+ }
44
+
45
+ const targets = [...new Set(pending.map((entry) => entry.asset).filter(Boolean))];
46
+ const { results } = await mapLimit(targets, LINK_CHECK_CONCURRENCY, exists);
47
+ const existsSet = new Set(targets.filter((_target, index) => results[index] === true));
48
+ for (const { link, asset } of pending) {
49
+ if (asset && existsSet.has(asset)) continue;
39
50
  add('error', 'broken-internal-link', `${link.from} links to missing ${link.to}`, {
40
51
  from: link.from,
41
52
  to: link.to,