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.
- package/CHANGELOG.md +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- 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 +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- 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 +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- 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
|
-
|
|
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
|
|
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(
|
|
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.
|
package/src/core/artifacts.js
CHANGED
|
@@ -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
|
+
}
|