snappy-prerender 2.6.0-rc.2
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/LICENSE +21 -0
- package/README.md +502 -0
- package/package.json +89 -0
- package/src/cli.js +476 -0
- package/src/core/blocking.js +28 -0
- package/src/core/browser.js +122 -0
- package/src/core/config.js +320 -0
- package/src/core/destination.js +45 -0
- package/src/core/form-state.js +23 -0
- package/src/core/index.js +2 -0
- package/src/core/init.js +136 -0
- package/src/core/inline-css.js +67 -0
- package/src/core/log.js +45 -0
- package/src/core/metadata-origin.js +39 -0
- package/src/core/minify.js +42 -0
- package/src/core/normalize.js +197 -0
- package/src/core/output.js +81 -0
- package/src/core/preload-manifest.js +28 -0
- package/src/core/render.js +228 -0
- package/src/core/resources.js +113 -0
- package/src/core/routes.js +150 -0
- package/src/core/run.js +523 -0
- package/src/core/runtime-styles.js +42 -0
- package/src/core/scheduler.js +83 -0
- package/src/core/server.js +216 -0
- package/src/core/settle.js +141 -0
- package/src/core/text-separators.js +30 -0
- package/src/core/verify.js +148 -0
- package/src/head/index.js +56 -0
- package/src/head/manager.js +230 -0
- package/src/head/tags.js +211 -0
- package/src/index.js +2 -0
- package/src/vite-plugin.js +49 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
CONFIG_FILE,
|
|
9
|
+
detectProjectDefaults,
|
|
10
|
+
PACKAGE_NAME,
|
|
11
|
+
parseRouteList,
|
|
12
|
+
renderConfigFile,
|
|
13
|
+
writeConfigFile,
|
|
14
|
+
} from './core/init.js';
|
|
15
|
+
import { createLogger } from './core/log.js';
|
|
16
|
+
import { prerender } from './core/run.js';
|
|
17
|
+
|
|
18
|
+
const HELP = `snappy-prerender — prerender a built SPA into static HTML
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
snappy-prerender init Setup wizard that writes snappy.config.js
|
|
22
|
+
snappy-prerender [sourceDir] [options]
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
-s, --source <dir> Built output directory (default: dist)
|
|
26
|
+
--base <path> Public base path of the app (default: /)
|
|
27
|
+
--include <routes> Routes to prerender, comma separated, repeatable (default: /)
|
|
28
|
+
--exclude <routes> Routes to skip, comma separated, repeatable
|
|
29
|
+
--no-crawl Disable link crawling, render only included routes
|
|
30
|
+
--max-depth <n> Crawl depth limit, 0 renders roots only (default: unlimited)
|
|
31
|
+
--max-routes <n> Stop after this many routes and fail the run (default: unlimited)
|
|
32
|
+
--concurrency <n> Parallel browser pages (default: derived from CPU count)
|
|
33
|
+
--timeout <ms> Per-route timeout (default: 30000)
|
|
34
|
+
--wait-for <selector> Wait for a selector on every route before capture
|
|
35
|
+
--browser <choice> auto | chrome | msedge | chromium | path (default: auto)
|
|
36
|
+
--no-browser-download Refuse the Chrome for Testing fallback download
|
|
37
|
+
--browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
|
|
38
|
+
--storage-state <file> Playwright storage state for authenticated routes
|
|
39
|
+
--no-verify Skip the hydration verification pass
|
|
40
|
+
--fail-on-hydration-error Fail the build when hydration errors are found
|
|
41
|
+
--fail-on-rerender Fail when a route re-renders instead of hydrating
|
|
42
|
+
--fail-on-page-error Fail when a page throws while it is being rendered
|
|
43
|
+
--block-third-party Block third-party requests while rendering
|
|
44
|
+
--no-block-third-party Allow third-party requests (the default)
|
|
45
|
+
--allowed-hosts <hosts> Third-party hosts to allow while blocking the rest, comma separated
|
|
46
|
+
--no-freeze-animations Do not neutralise CSS animations before capture
|
|
47
|
+
--no-capture-runtime-styles Do not fold CSSOM-only styles into the output
|
|
48
|
+
--no-capture-form-state Do not sync checked and selected into the output
|
|
49
|
+
--scroll Scroll through each page to trigger lazy content
|
|
50
|
+
--flat Write about.html instead of about/index.html
|
|
51
|
+
--not-found <route> Route emitted as 404.html (default: /404)
|
|
52
|
+
--destination <dir> Write output to another directory (default: in place)
|
|
53
|
+
--save-as <format> html | png | jpeg (default: html)
|
|
54
|
+
--user-agent <value> User agent used while rendering (default: SnappyPrerender)
|
|
55
|
+
--metadata-site-url <url> Public site URL for canonical and og:url
|
|
56
|
+
--metadata-site-name <name> Site name used for og:site_name
|
|
57
|
+
--metadata-title-template <t> Title template, for example '%s | Example'
|
|
58
|
+
--metadata-default-image <url> Share image for routes without one
|
|
59
|
+
--metadata-trailing-slash <mode> preserve | always | never
|
|
60
|
+
--inline-css <strategy> inline | critical (critical needs the beasties package)
|
|
61
|
+
--critical-css-preload <mode> Swap for deferred CSS: none | body | media | swap | swap-high | swap-low | js | js-lazy
|
|
62
|
+
--minify-html Minify the generated HTML
|
|
63
|
+
--minify-css Minify CSS inlined into the HTML
|
|
64
|
+
--preconnect-third-party Add preconnect hints for third-party origins (default: on)
|
|
65
|
+
--no-preconnect-third-party Disable preconnect hints
|
|
66
|
+
--preload-images Add preload hints for images on the page
|
|
67
|
+
--preload-manifest Write preload-manifest.json with Link header hints
|
|
68
|
+
--ignore-for-preload <names> File names excluded from the manifest, comma separated
|
|
69
|
+
--cache-ajax-requests Expose captured JSON responses as window.snapStore
|
|
70
|
+
--remove-scripts Strip every script tag from the output
|
|
71
|
+
--remove-styles Strip every style tag from the output
|
|
72
|
+
--async-scripts Mark external scripts async
|
|
73
|
+
--external-scripts Write injected state scripts as same-origin files, for a strict CSP
|
|
74
|
+
--csp <mode> off | strict: strict makes the output work under script-src 'self'
|
|
75
|
+
--no-remove-blobs Keep blob stylesheet links instead of dropping them
|
|
76
|
+
--ignore-https-errors Ignore TLS errors while rendering
|
|
77
|
+
--browser-args <args> Extra browser launch arguments, comma separated
|
|
78
|
+
--url <url> App URL to connect to when using --serve-cmd
|
|
79
|
+
--serve-cmd <command> Command that starts the app server instead of static serving
|
|
80
|
+
--dry-run Render and report without writing files
|
|
81
|
+
--log-level <level> silent | error | warn | info | debug (default: info)
|
|
82
|
+
--yes, -y Accept every default, for a shell without a terminal
|
|
83
|
+
--force Replace an existing snappy.config.js
|
|
84
|
+
-c, --config <file> Config file (default: snappy.config.js if present)
|
|
85
|
+
-h, --help Show this help
|
|
86
|
+
-v, --version Show version
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* CLI entry point. Loads an optional config file, lets explicit flags win over it,
|
|
91
|
+
* runs the prerenderer, and exits non-zero when the report marks the run failed.
|
|
92
|
+
*/
|
|
93
|
+
async function main() {
|
|
94
|
+
const parsed = parseArgs({
|
|
95
|
+
options: {
|
|
96
|
+
source: { type: 'string', short: 's' },
|
|
97
|
+
base: { type: 'string' },
|
|
98
|
+
include: { type: 'string', multiple: true },
|
|
99
|
+
exclude: { type: 'string', multiple: true },
|
|
100
|
+
'no-crawl': { type: 'boolean' },
|
|
101
|
+
'max-depth': { type: 'string' },
|
|
102
|
+
'max-routes': { type: 'string' },
|
|
103
|
+
concurrency: { type: 'string' },
|
|
104
|
+
timeout: { type: 'string' },
|
|
105
|
+
'wait-for': { type: 'string' },
|
|
106
|
+
browser: { type: 'string' },
|
|
107
|
+
'no-browser-download': { type: 'boolean' },
|
|
108
|
+
'browser-download-hash': { type: 'string' },
|
|
109
|
+
'storage-state': { type: 'string' },
|
|
110
|
+
'no-verify': { type: 'boolean' },
|
|
111
|
+
'fail-on-hydration-error': { type: 'boolean' },
|
|
112
|
+
'fail-on-rerender': { type: 'boolean' },
|
|
113
|
+
'fail-on-page-error': { type: 'boolean' },
|
|
114
|
+
'no-block-third-party': { type: 'boolean' },
|
|
115
|
+
'block-third-party': { type: 'boolean' },
|
|
116
|
+
'allowed-hosts': { type: 'string', multiple: true },
|
|
117
|
+
'no-freeze-animations': { type: 'boolean' },
|
|
118
|
+
'no-capture-runtime-styles': { type: 'boolean' },
|
|
119
|
+
'no-capture-form-state': { type: 'boolean' },
|
|
120
|
+
scroll: { type: 'boolean' },
|
|
121
|
+
flat: { type: 'boolean' },
|
|
122
|
+
'not-found': { type: 'string' },
|
|
123
|
+
destination: { type: 'string' },
|
|
124
|
+
'save-as': { type: 'string' },
|
|
125
|
+
'user-agent': { type: 'string' },
|
|
126
|
+
'metadata-site-url': { type: 'string' },
|
|
127
|
+
'metadata-site-name': { type: 'string' },
|
|
128
|
+
'metadata-title-template': { type: 'string' },
|
|
129
|
+
'metadata-default-image': { type: 'string' },
|
|
130
|
+
'metadata-trailing-slash': { type: 'string' },
|
|
131
|
+
'inline-css': { type: 'string' },
|
|
132
|
+
'critical-css-preload': { type: 'string' },
|
|
133
|
+
csp: { type: 'string' },
|
|
134
|
+
'minify-html': { type: 'boolean' },
|
|
135
|
+
'minify-css': { type: 'boolean' },
|
|
136
|
+
'no-preconnect-third-party': { type: 'boolean' },
|
|
137
|
+
'preload-images': { type: 'boolean' },
|
|
138
|
+
'preload-manifest': { type: 'boolean' },
|
|
139
|
+
'ignore-for-preload': { type: 'string', multiple: true },
|
|
140
|
+
'cache-ajax-requests': { type: 'boolean' },
|
|
141
|
+
'remove-scripts': { type: 'boolean' },
|
|
142
|
+
'remove-styles': { type: 'boolean' },
|
|
143
|
+
'async-scripts': { type: 'boolean' },
|
|
144
|
+
'external-scripts': { type: 'boolean' },
|
|
145
|
+
'no-remove-blobs': { type: 'boolean' },
|
|
146
|
+
'ignore-https-errors': { type: 'boolean' },
|
|
147
|
+
'browser-args': { type: 'string', multiple: true },
|
|
148
|
+
url: { type: 'string' },
|
|
149
|
+
'serve-cmd': { type: 'string' },
|
|
150
|
+
'dry-run': { type: 'boolean' },
|
|
151
|
+
'log-level': { type: 'string' },
|
|
152
|
+
yes: { type: 'boolean', short: 'y' },
|
|
153
|
+
force: { type: 'boolean' },
|
|
154
|
+
config: { type: 'string', short: 'c' },
|
|
155
|
+
help: { type: 'boolean', short: 'h' },
|
|
156
|
+
version: { type: 'boolean', short: 'v' },
|
|
157
|
+
},
|
|
158
|
+
allowPositionals: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (parsed.values.help) {
|
|
162
|
+
process.stdout.write(HELP);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (parsed.values.version) {
|
|
166
|
+
process.stdout.write(`${readVersion()}\n`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (parsed.positionals[0] === 'init') {
|
|
170
|
+
await runInit(parsed);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fileConfig = await loadConfigFile(parsed.values.config);
|
|
175
|
+
const cli = cliOptions(parsed);
|
|
176
|
+
const options = { ...fileConfig, ...cli };
|
|
177
|
+
// Metadata flags are partial: they refine a config file value instead of replacing it.
|
|
178
|
+
if (cli.metadata) options.metadata = { ...(fileConfig.metadata ?? {}), ...cli.metadata };
|
|
179
|
+
if (!options.sourceDir) options.sourceDir = 'dist';
|
|
180
|
+
|
|
181
|
+
const log = createLogger(options.logLevel ?? 'info');
|
|
182
|
+
const report = await prerender(options, { log });
|
|
183
|
+
if (!report.ok) process.exitCode = 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Translates parsed flags into prerender options, only including flags the user
|
|
188
|
+
* actually passed so config-file values are never clobbered by defaults.
|
|
189
|
+
*/
|
|
190
|
+
function cliOptions({ values, positionals }) {
|
|
191
|
+
const options = {};
|
|
192
|
+
if (positionals[0] !== undefined) options.sourceDir = positionals[0];
|
|
193
|
+
if (values.source !== undefined) options.sourceDir = values.source;
|
|
194
|
+
if (values.base !== undefined) options.base = values.base;
|
|
195
|
+
if (values.include !== undefined) options.include = splitList(values.include);
|
|
196
|
+
if (values.exclude !== undefined) options.exclude = splitList(values.exclude);
|
|
197
|
+
if (values['no-crawl']) options.crawl = false;
|
|
198
|
+
if (values['max-depth'] !== undefined) options.maxDepth = Number(values['max-depth']);
|
|
199
|
+
if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
|
|
200
|
+
if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
|
|
201
|
+
if (values.timeout !== undefined) options.timeout = Number(values.timeout);
|
|
202
|
+
if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
|
|
203
|
+
if (values.browser !== undefined) options.browser = values.browser;
|
|
204
|
+
if (values['no-browser-download']) options.browserDownload = false;
|
|
205
|
+
if (values['browser-download-hash'] !== undefined) {
|
|
206
|
+
options.browserDownloadHash = values['browser-download-hash'];
|
|
207
|
+
}
|
|
208
|
+
if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
|
|
209
|
+
if (values['no-verify']) options.verify = false;
|
|
210
|
+
if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
|
|
211
|
+
if (values['fail-on-rerender']) options.failOnRerender = true;
|
|
212
|
+
if (values['fail-on-page-error']) options.failOnPageError = true;
|
|
213
|
+
if (values['no-block-third-party']) options.blockThirdParty = false;
|
|
214
|
+
if (values['block-third-party']) options.blockThirdParty = true;
|
|
215
|
+
if (values['allowed-hosts'] !== undefined)
|
|
216
|
+
options.allowedHosts = splitList(values['allowed-hosts']);
|
|
217
|
+
if (values['no-freeze-animations']) options.freezeAnimations = false;
|
|
218
|
+
if (values['no-capture-runtime-styles']) options.captureRuntimeStyles = false;
|
|
219
|
+
if (values['no-capture-form-state']) options.captureFormState = false;
|
|
220
|
+
if (values.scroll) options.scrollToBottom = true;
|
|
221
|
+
if (values.flat) options.flatOutput = true;
|
|
222
|
+
if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
|
|
223
|
+
if (values.destination !== undefined) options.destination = values.destination;
|
|
224
|
+
if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
|
|
225
|
+
if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
|
|
226
|
+
const metadata = metadataOptions(values);
|
|
227
|
+
if (metadata) options.metadata = metadata;
|
|
228
|
+
if (values['inline-css'] !== undefined) options.inlineCss = values['inline-css'];
|
|
229
|
+
if (values.csp !== undefined) options.csp = values.csp;
|
|
230
|
+
if (values['critical-css-preload'] !== undefined) {
|
|
231
|
+
options.criticalCssPreload =
|
|
232
|
+
values['critical-css-preload'] === 'none' ? false : values['critical-css-preload'];
|
|
233
|
+
}
|
|
234
|
+
if (values['minify-html']) options.minifyHtml = true;
|
|
235
|
+
if (values['minify-css']) options.minifyCss = true;
|
|
236
|
+
if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
|
|
237
|
+
if (values['preload-images']) options.preloadImages = true;
|
|
238
|
+
if (values['preload-manifest']) options.preloadManifest = true;
|
|
239
|
+
if (values['ignore-for-preload'] !== undefined) {
|
|
240
|
+
options.ignoreForPreload = splitList(values['ignore-for-preload']);
|
|
241
|
+
}
|
|
242
|
+
if (values['cache-ajax-requests']) options.cacheAjaxRequests = true;
|
|
243
|
+
if (values['remove-scripts']) options.removeScriptTags = true;
|
|
244
|
+
if (values['remove-styles']) options.removeStyleTags = true;
|
|
245
|
+
if (values['async-scripts']) options.asyncScriptTags = true;
|
|
246
|
+
if (values['external-scripts']) options.externalScripts = true;
|
|
247
|
+
if (values['no-remove-blobs']) options.removeBlobs = false;
|
|
248
|
+
if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
|
|
249
|
+
if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
|
|
250
|
+
if (values.url !== undefined) options.url = values.url;
|
|
251
|
+
if (values['serve-cmd'] !== undefined) options.serveCmd = values['serve-cmd'];
|
|
252
|
+
if (values['dry-run']) options.dryRun = true;
|
|
253
|
+
if (values['log-level'] !== undefined) options.logLevel = values['log-level'];
|
|
254
|
+
return options;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Splits comma separated flag values and drops empty entries.
|
|
258
|
+
function splitList(entries) {
|
|
259
|
+
return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Setup wizard: asks for the handful of values that decide what gets prerendered and what the
|
|
264
|
+
* metadata says, then writes snappy.config.js. Flags cover every question, so
|
|
265
|
+
* `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
|
|
266
|
+
* terminal.
|
|
267
|
+
*/
|
|
268
|
+
async function runInit({ values }) {
|
|
269
|
+
const log = createLogger(values['log-level'] ?? 'info');
|
|
270
|
+
const cwd = process.cwd();
|
|
271
|
+
if (!values.force && fs.existsSync(CONFIG_FILE)) {
|
|
272
|
+
throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
|
|
273
|
+
}
|
|
274
|
+
if (!values.yes && !process.stdin.isTTY) {
|
|
275
|
+
throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const detected = await detectProjectDefaults(cwd);
|
|
279
|
+
const flags = cliOptions({ values, positionals: [] });
|
|
280
|
+
const defaults = defaultsFromFlags(detected, flags);
|
|
281
|
+
const answers = values.yes ? defaults : await askAnswers(defaults, log);
|
|
282
|
+
const cleaned = validatedAnswers(answers, log);
|
|
283
|
+
|
|
284
|
+
await writeConfigFile({
|
|
285
|
+
dir: cwd,
|
|
286
|
+
contents: renderConfigFile(cleaned),
|
|
287
|
+
force: values.force === true,
|
|
288
|
+
});
|
|
289
|
+
log.success(`Wrote ${CONFIG_FILE}`);
|
|
290
|
+
printNextSteps({ detected, log });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Turns the flags the CLI already understands into the wizard's starting answers.
|
|
294
|
+
function defaultsFromFlags(detected, flags) {
|
|
295
|
+
return {
|
|
296
|
+
sourceDir: flags.sourceDir ?? detected.sourceDir,
|
|
297
|
+
base: flags.base ?? '/',
|
|
298
|
+
include: flags.include ?? ['/'],
|
|
299
|
+
crawl: flags.crawl ?? true,
|
|
300
|
+
siteUrl: flags.metadata?.siteUrl ?? '',
|
|
301
|
+
siteName: flags.metadata?.siteName ?? '',
|
|
302
|
+
titleTemplate: flags.metadata?.titleTemplate ?? '',
|
|
303
|
+
defaultImage: flags.metadata?.defaultImage ?? '',
|
|
304
|
+
trailingSlash: flags.metadata?.trailingSlash ?? '',
|
|
305
|
+
csp: flags.csp ?? 'off',
|
|
306
|
+
externalScripts: flags.externalScripts,
|
|
307
|
+
criticalCssPreload: flags.criticalCssPreload,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Asks every question in order, closing the terminal interface whatever the outcome.
|
|
312
|
+
async function askAnswers(defaults, log) {
|
|
313
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
314
|
+
try {
|
|
315
|
+
log.info('A few questions, then a config file with only the options you set.');
|
|
316
|
+
return {
|
|
317
|
+
sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
|
|
318
|
+
base: await ask(rl, 'Public base path', defaults.base),
|
|
319
|
+
include: parseRouteList(
|
|
320
|
+
await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
|
|
321
|
+
),
|
|
322
|
+
crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
|
|
323
|
+
siteUrl: await ask(
|
|
324
|
+
rl,
|
|
325
|
+
'Public site URL for canonical and og:url, blank to skip',
|
|
326
|
+
defaults.siteUrl,
|
|
327
|
+
),
|
|
328
|
+
siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
|
|
329
|
+
titleTemplate: await ask(
|
|
330
|
+
rl,
|
|
331
|
+
"Title template such as '%s | Example', blank to skip",
|
|
332
|
+
defaults.titleTemplate,
|
|
333
|
+
),
|
|
334
|
+
csp: (await askYesNo(
|
|
335
|
+
rl,
|
|
336
|
+
"Serve a strict CSP (script-src 'self', no inline scripts)?",
|
|
337
|
+
defaults.csp === 'strict',
|
|
338
|
+
))
|
|
339
|
+
? 'strict'
|
|
340
|
+
: 'off',
|
|
341
|
+
defaultImage: defaults.defaultImage,
|
|
342
|
+
trailingSlash: defaults.trailingSlash,
|
|
343
|
+
};
|
|
344
|
+
} finally {
|
|
345
|
+
rl.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Asks one question, showing the value Enter will accept.
|
|
350
|
+
async function ask(rl, question, fallback) {
|
|
351
|
+
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
352
|
+
return answer.length > 0 ? answer : fallback;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Asks a yes/no question, treating Enter as the default.
|
|
356
|
+
async function askYesNo(rl, question, fallback) {
|
|
357
|
+
const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
|
|
358
|
+
.trim()
|
|
359
|
+
.toLowerCase();
|
|
360
|
+
if (answer.length === 0) return fallback;
|
|
361
|
+
return answer === 'y' || answer === 'yes';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Drops values that would make a broken config, so a typo becomes a warning instead.
|
|
365
|
+
function validatedAnswers(answers, log) {
|
|
366
|
+
const cleaned = { ...answers };
|
|
367
|
+
if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
|
|
368
|
+
log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
|
|
369
|
+
cleaned.siteUrl = '';
|
|
370
|
+
}
|
|
371
|
+
if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
|
|
372
|
+
log.warn('ignoring the title template: it has no %s placeholder');
|
|
373
|
+
cleaned.titleTemplate = '';
|
|
374
|
+
}
|
|
375
|
+
cleaned.include = parseRouteList((cleaned.include ?? ['/']).join(','));
|
|
376
|
+
return cleaned;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Prints what to do next: install if needed, run it, and where the head layer goes.
|
|
380
|
+
function printNextSteps({ detected, log }) {
|
|
381
|
+
const run = runnerCommand(detected.runner, PACKAGE_NAME);
|
|
382
|
+
const install = installCommand(detected.runner);
|
|
383
|
+
log.info('');
|
|
384
|
+
log.info('Next steps:');
|
|
385
|
+
if (!detected.installed) log.info(` Install ${install}`);
|
|
386
|
+
log.info(` Dry run ${run} --dry-run`);
|
|
387
|
+
log.info(` Prerender ${run}`);
|
|
388
|
+
log.info('');
|
|
389
|
+
log.info('Per-route titles and meta tags come from the head layer:');
|
|
390
|
+
log.info(" import { Head } from 'snappy-prerender/head';");
|
|
391
|
+
log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
|
|
392
|
+
if (detected.usesVite) {
|
|
393
|
+
log.info('');
|
|
394
|
+
log.info('Or run it inside the build (vite.config.js):');
|
|
395
|
+
log.info(" import snappy from 'snappy-prerender';");
|
|
396
|
+
log.info(' plugins: [react(), snappy()],');
|
|
397
|
+
log.info(
|
|
398
|
+
` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Builds the command that runs a locally installed binary for the project's package runner.
|
|
404
|
+
function runnerCommand(runner, binary) {
|
|
405
|
+
if (runner === 'pnpm') return `pnpm exec ${binary}`;
|
|
406
|
+
if (runner === 'yarn') return `yarn ${binary}`;
|
|
407
|
+
return `npx ${binary}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Builds the command that installs this package as a dev dependency.
|
|
411
|
+
function installCommand(runner) {
|
|
412
|
+
if (runner === 'pnpm') return `pnpm add -D ${PACKAGE_NAME}`;
|
|
413
|
+
if (runner === 'yarn') return `yarn add -D ${PACKAGE_NAME}`;
|
|
414
|
+
return `npm install --save-dev ${PACKAGE_NAME}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Collects the metadata flags, or null when none was passed so a config file value survives.
|
|
418
|
+
function metadataOptions(values) {
|
|
419
|
+
const metadata = {};
|
|
420
|
+
if (values['metadata-site-url'] !== undefined) metadata.siteUrl = values['metadata-site-url'];
|
|
421
|
+
if (values['metadata-site-name'] !== undefined) {
|
|
422
|
+
metadata.siteName = values['metadata-site-name'];
|
|
423
|
+
}
|
|
424
|
+
if (values['metadata-title-template'] !== undefined) {
|
|
425
|
+
metadata.titleTemplate = values['metadata-title-template'];
|
|
426
|
+
}
|
|
427
|
+
if (values['metadata-default-image'] !== undefined) {
|
|
428
|
+
metadata.defaultImage = values['metadata-default-image'];
|
|
429
|
+
}
|
|
430
|
+
if (values['metadata-trailing-slash'] !== undefined) {
|
|
431
|
+
metadata.trailingSlash = values['metadata-trailing-slash'];
|
|
432
|
+
}
|
|
433
|
+
return Object.keys(metadata).length > 0 ? metadata : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Loads a JS config file. An explicitly requested file must exist; the implicit
|
|
438
|
+
* snappy.config.js is optional. The default export must be a plain options object.
|
|
439
|
+
*/
|
|
440
|
+
async function loadConfigFile(explicitPath) {
|
|
441
|
+
if (explicitPath === undefined) {
|
|
442
|
+
if (!fs.existsSync('snappy.config.js')) return {};
|
|
443
|
+
explicitPath = 'snappy.config.js';
|
|
444
|
+
} else if (!fs.existsSync(explicitPath)) {
|
|
445
|
+
throw new Error(`Config file not found: ${explicitPath}`);
|
|
446
|
+
}
|
|
447
|
+
const module = await import(pathToFileURL(explicitPath).href);
|
|
448
|
+
const loaded = module.default ?? {};
|
|
449
|
+
if (typeof loaded !== 'object' || loaded === null || Array.isArray(loaded)) {
|
|
450
|
+
throw new Error(`Config file must export a plain options object: ${explicitPath}`);
|
|
451
|
+
}
|
|
452
|
+
return loaded;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Reads the package version without importing JSON, keeping Node 20 compatible.
|
|
456
|
+
function readVersion() {
|
|
457
|
+
const packageJson = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
458
|
+
return JSON.parse(packageJson).version;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Reports whether the user asked for debug logging, which also enables full stack
|
|
463
|
+
* traces on unexpected failures to make OSS bug reports actionable.
|
|
464
|
+
*/
|
|
465
|
+
function wantsDebugStack() {
|
|
466
|
+
const index = process.argv.indexOf('--log-level');
|
|
467
|
+
return (
|
|
468
|
+
process.argv.includes('--log-level=debug') ||
|
|
469
|
+
(index !== -1 && process.argv[index + 1] === 'debug')
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
main().catch((error) => {
|
|
474
|
+
process.stderr.write(`${wantsDebugStack() ? error.stack : error.message}\n`);
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aborts requests to third-party origins so analytics, ads and external widgets
|
|
3
|
+
* cannot mutate the DOM mid-capture or make output non-deterministic. Used by both
|
|
4
|
+
* the render pass and the hydration verification pass so they observe the same page.
|
|
5
|
+
*/
|
|
6
|
+
export async function blockThirdPartyRequests(page, origin, allowedHosts) {
|
|
7
|
+
const allowed = new Set(allowedHosts.map((host) => host.toLowerCase()));
|
|
8
|
+
await page.route('**/*', (route) => {
|
|
9
|
+
const url = route.request().url();
|
|
10
|
+
if (/^(data|blob|about|chrome):/.test(url)) return allow(route);
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = new URL(url);
|
|
14
|
+
} catch {
|
|
15
|
+
return allow(route);
|
|
16
|
+
}
|
|
17
|
+
if (parsed.origin === origin) return allow(route);
|
|
18
|
+
if (allowed.has(parsed.hostname.toLowerCase())) return allow(route);
|
|
19
|
+
// A route can already be handled or cancelled, and that rejection is not this hook's to
|
|
20
|
+
// report, so it is swallowed rather than surfacing as an unhandled rejection.
|
|
21
|
+
return route.abort().catch(() => {});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Continues a request, ignoring the rejection that follows an already-handled route.
|
|
26
|
+
function allow(route) {
|
|
27
|
+
return route.continue().catch(() => {});
|
|
28
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as browsers from '@puppeteer/browsers';
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import { isExecutablePath } from './config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a usable Chromium-compatible browser, preferring the browser already on
|
|
10
|
+
* the machine, then any Playwright cache, then a one-time Chrome for Testing
|
|
11
|
+
* download. Returns the launched browser plus a human-readable source for logs.
|
|
12
|
+
*/
|
|
13
|
+
export async function launchBrowser(config, log) {
|
|
14
|
+
if (isExecutablePath(config.browser)) {
|
|
15
|
+
return {
|
|
16
|
+
browser: await launch(config, { executablePath: config.browser }),
|
|
17
|
+
source: config.browser,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const attempts = [];
|
|
22
|
+
const channels =
|
|
23
|
+
config.browser === 'auto'
|
|
24
|
+
? ['chrome', 'msedge']
|
|
25
|
+
: config.browser === 'chromium'
|
|
26
|
+
? []
|
|
27
|
+
: [config.browser];
|
|
28
|
+
|
|
29
|
+
for (const channel of channels) {
|
|
30
|
+
try {
|
|
31
|
+
const browser = await launch(config, { channel });
|
|
32
|
+
return { browser, source: `system ${channel}` };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
attempts.push(`${channel}: ${firstLine(error.message)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const browser = await launch(config, {});
|
|
40
|
+
return { browser, source: 'playwright cache' };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
attempts.push(`playwright cache: ${firstLine(error.message)}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!config.browserDownload) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No usable browser found and browserDownload is disabled. Tried:\n ${attempts.join('\n ')}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const executablePath = await downloadChrome(config, log);
|
|
52
|
+
return {
|
|
53
|
+
browser: await launch(config, { executablePath }),
|
|
54
|
+
source: `downloaded ${executablePath}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Launches Chromium with the configured headless mode, extra args and target.
|
|
59
|
+
function launch(config, options) {
|
|
60
|
+
return chromium.launch({
|
|
61
|
+
headless: config.headless,
|
|
62
|
+
args: config.browserArgs.length > 0 ? config.browserArgs : undefined,
|
|
63
|
+
...options,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keeps launch failure summaries to a single line in the aggregated error.
|
|
68
|
+
function firstLine(message) {
|
|
69
|
+
return String(message).split('\n')[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Downloads Chrome for Testing into the tool's own cache directory. Used only when
|
|
74
|
+
* neither a system browser nor a Playwright-managed browser is available. The
|
|
75
|
+
* archive comes from Google's version-pinned HTTPS bucket via @puppeteer/browsers,
|
|
76
|
+
* which verifies it against browserDownloadHash when one is configured and deletes
|
|
77
|
+
* it on a mismatch. Without a configured hash the download is trusted on the
|
|
78
|
+
* strength of HTTPS alone, which is logged as a warning.
|
|
79
|
+
*/
|
|
80
|
+
async function downloadChrome(config, log) {
|
|
81
|
+
const cacheDir = browserCacheDir();
|
|
82
|
+
const buildId = await browsers.resolveBuildId(
|
|
83
|
+
browsers.Browser.CHROME,
|
|
84
|
+
browsers.detectBrowserPlatform(),
|
|
85
|
+
'stable',
|
|
86
|
+
);
|
|
87
|
+
if (config.browserDownloadHash) {
|
|
88
|
+
log.info(
|
|
89
|
+
`Downloading Chrome for Testing ${buildId}, verifying SHA-256 against browserDownloadHash`,
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
log.warn(
|
|
93
|
+
`Downloading Chrome for Testing ${buildId} to ${cacheDir} without a checksum; set browserDownloadHash to pin one`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const installed = await browsers.install({
|
|
97
|
+
browser: browsers.Browser.CHROME,
|
|
98
|
+
buildId,
|
|
99
|
+
cacheDir,
|
|
100
|
+
expectedHash: config.browserDownloadHash ?? undefined,
|
|
101
|
+
downloadProgressCallback: (downloaded, total) =>
|
|
102
|
+
log.debug(` downloaded ${total > 0 ? Math.round((downloaded / total) * 100) : 0}%`),
|
|
103
|
+
});
|
|
104
|
+
if (!fs.existsSync(installed.executablePath)) {
|
|
105
|
+
throw new Error(`Chrome download reported success but ${installed.executablePath} is missing`);
|
|
106
|
+
}
|
|
107
|
+
return installed.executablePath;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Cache location for downloaded browsers. Overridable through the
|
|
112
|
+
* SNAPPY_BROWSER_CACHE_DIR environment variable.
|
|
113
|
+
*/
|
|
114
|
+
export function browserCacheDir() {
|
|
115
|
+
const override = process.env.SNAPPY_BROWSER_CACHE_DIR;
|
|
116
|
+
if (override) return override;
|
|
117
|
+
const root =
|
|
118
|
+
process.platform === 'win32'
|
|
119
|
+
? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
|
|
120
|
+
: process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
121
|
+
return path.join(root, 'snappy-prerender', 'browsers');
|
|
122
|
+
}
|