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.
- package/CHANGELOG.md +55 -0
- package/README.md +168 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
|
@@ -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
|
|
package/src/core/audit/links.js
CHANGED
|
@@ -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
|
-
|
|
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,
|