snappy-prerender 2.6.0 → 2.7.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.
@@ -0,0 +1,45 @@
1
+ # Migrating from react-snap
2
+
3
+ `snappy-prerender init` reads a `reactSnap` block from `package.json` and uses it as the
4
+ wizard's defaults, so an existing project can move without retyping its settings. The
5
+ migration table is printed after the config is written, together with any ignored or
6
+ unknown keys.
7
+
8
+ ## Option mapping
9
+
10
+ | react-snap | snappy-prerender |
11
+ | --- | --- |
12
+ | `source` | `sourceDir` |
13
+ | `puppeteerArgs` | `browserArgs` |
14
+ | `puppeteerIgnoreHTTPSErrors` | `ignoreHTTPSErrors` |
15
+ | `skipThirdPartyRequests` | `blockThirdParty` |
16
+ | `fixInsertRule` | `captureRuntimeStyles` |
17
+ | `fixFormFields` | `captureFormState` |
18
+ | `http2PushManifest` | `preloadManifest` |
19
+
20
+ These keep their name: `include`, `exclude`, `crawl`, `concurrency`, `timeout`, `waitFor`,
21
+ `inlineCss`, `minifyHtml`, `minifyCss`, `cacheAjaxRequests`, `preconnectThirdParty`,
22
+ `preloadImages`, `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`,
23
+ `userAgent`, `viewport`, `saveAs`, `destination`.
24
+
25
+ Ignored, with a printed explanation: `port` (the prerenderer serves the build itself on an
26
+ ephemeral loopback port) and `sourceMaps` (declared by react-snap but never read).
27
+
28
+ ## Other differences
29
+
30
+ Kept: crawling, include, concurrency, viewport, waitFor, executable path, external server,
31
+ third-party skipping (now on by default), `userAgent`, `inlineCss`, `minifyHtml`,
32
+ `minifyCss`, `cacheAjaxRequests`, `snapSaveState`, `preconnectThirdParty`, `preloadImages`,
33
+ `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`, `destination`,
34
+ `saveAs` (html/png/jpeg).
35
+
36
+ Changed: `http2PushManifest` became `preloadManifest` (browsers removed HTTP/2 push),
37
+ `fixInsertRule` became `captureRuntimeStyles` and also covers constructable stylesheets,
38
+ `fixFormFields` became `captureFormState`, `puppeteerArgs` became `browserArgs`,
39
+ `puppeteerIgnoreHTTPSErrors` became `ignoreHTTPSErrors`, `skipThirdPartyRequests` became
40
+ `blockThirdParty` with the same default of allowing them, `exclude` and `maxRoutes` are
41
+ new, and `notFoundRoute` replaces the `/404` include convention.
42
+
43
+ Dropped: `sourceMaps`, `fixWebpackChunksIssue` and `fixInsertRule` (webpack/Chrome-era
44
+ workarounds), `port`, `puppeteer.cache`, and the preload polyfill (modulepreload is
45
+ universally supported).
package/docs/report.md ADDED
@@ -0,0 +1,102 @@
1
+ # Report and audit
2
+
3
+ `reportPath` (CLI `--report <file>`) writes a versioned JSON document after the run. The
4
+ file is written through a temporary file, so a reader never sees a partial report, and it
5
+ is skipped in `--dry-run`.
6
+
7
+ ```json
8
+ {
9
+ "version": 1,
10
+ "generatedAt": "2026-10-28T00:00:00.000Z",
11
+ "durationMs": 0,
12
+ "ok": true,
13
+ "summary": { "routes": 0, "files": 0, "errors": 0, "warnings": 0 },
14
+ "verification": {
15
+ "ok": true,
16
+ "modes": { "hydrated": 0, "re-rendered": 0, "undetected": 0, "unknown": 0 },
17
+ "frameworks": { "react": 0 },
18
+ "skipped": 0,
19
+ "errors": []
20
+ },
21
+ "routes": [
22
+ {
23
+ "route": "/",
24
+ "order": 0,
25
+ "file": "index.html",
26
+ "status": "written",
27
+ "bytes": 0,
28
+ "depth": 0,
29
+ "attempts": 1,
30
+ "httpStatus": 200,
31
+ "finalUrl": "http://127.0.0.1:1234/",
32
+ "mode": "hydrated",
33
+ "framework": "react",
34
+ "empty": false,
35
+ "notFound": false,
36
+ "noindex": false,
37
+ "meta": {
38
+ "title": "",
39
+ "description": "",
40
+ "canonical": "",
41
+ "robots": "",
42
+ "h1Count": 1,
43
+ "alternates": [],
44
+ "images": [],
45
+ "inlineStyleAttributes": 0
46
+ },
47
+ "hydrationErrors": [],
48
+ "pageErrors": [],
49
+ "consoleErrors": [],
50
+ "failedRequests": [],
51
+ "firstMismatch": null
52
+ }
53
+ ],
54
+ "audit": { "errors": [], "warnings": [], "linksChecked": 0 },
55
+ "sitemap": null,
56
+ "preloadManifest": null,
57
+ "truncated": null,
58
+ "notFoundRoutes": [],
59
+ "duplicateOutputs": [],
60
+ "routeSources": [],
61
+ "cspSkipped": [],
62
+ "traces": []
63
+ }
64
+ ```
65
+
66
+ The document never includes configuration, `storageState`, captured AJAX state, cookies,
67
+ tokens, request bodies or full asset URLs containing credentials. URLs in the JSON are
68
+ reduced to their origin and path, and the configured `storageState` path is redacted
69
+ wherever it appears in a message. `order` is the crawl acceptance order; with
70
+ `maxRoutes` or `routePriority` it follows the deterministic wave order rather than the
71
+ alphabetical write order.
72
+
73
+ ## Audit rules
74
+
75
+ The audit runs by default and reports findings without failing the build. Set
76
+ `failOnAuditError: true` (CLI `--fail-on-audit-error`) to make `ok` false when an error is
77
+ present. `auditIgnoreLinks` (CLI `--audit-ignore-links`) excludes known dynamic links.
78
+
79
+ Errors:
80
+
81
+ - `route-error` — a route failed while rendering, or its output could not be written.
82
+ - `route-cap` — `maxRoutes` stopped the crawl before every discovered route was accepted.
83
+ - `empty-page` — a route rendered no app content.
84
+ - `duplicate-output` — two routes produced byte-identical HTML.
85
+ - `broken-internal-link` — an internal link with no written route or output file.
86
+ - `duplicate-title` — two routes share a title.
87
+ - `multiple-titles` — a page ships more than one document `<title>`.
88
+ - `sitemap-missing-page` — an indexable page is absent from the sitemap.
89
+ - `sitemap-unknown-page` — the sitemap lists a URL with no indexable page.
90
+ - `sitemap-noindex-page` — the sitemap lists a noindex or 404 page.
91
+
92
+ Warnings:
93
+
94
+ - `missing-title`, `missing-description`, `missing-canonical`.
95
+ - `duplicate-description`.
96
+ - `heading-count` — zero or more than one `<h1>`.
97
+ - `canonical-mismatch` — canonical differs from the expected public URL.
98
+ - `build-origin-metadata` — a tag still points at the build server.
99
+ - `orphan-page` — an indexable page no written page links to.
100
+ - `inline-style-attributes` — `csp: 'hash'` cannot cover `style=""` attributes.
101
+ - `existing-csp-meta` — a CSP meta already existed, so hashes were not injected.
102
+ - `csp-console-violation` — the browser reported a request blocked by the generated policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snappy-prerender",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Drop-in prerendering for existing single-page apps. Renders your built app in a real browser, writes static HTML per route, reports React hydration mismatches, and optimises the output with critical CSS, link hints and cached AJAX state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "files": [
42
42
  "src",
43
+ "docs",
43
44
  "CHANGELOG.md",
44
45
  "LICENSE",
45
46
  "README.md"
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { resolveConfig } from './core/config.js';
4
+ import {
5
+ CONFIG_FILE,
6
+ configValuesFromAnswers,
7
+ detectProjectDefaults,
8
+ PACKAGE_NAME,
9
+ parseRouteList,
10
+ renderConfigFile,
11
+ writeConfigFile,
12
+ } from './core/init.js';
13
+
14
+ /**
15
+ * Setup wizard: asks for the handful of values that decide what gets prerendered and what the
16
+ * metadata says, then writes snappy.config.js. Flags cover every question, so
17
+ * `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
18
+ * terminal.
19
+ */
20
+ export async function runInit({ values, flags, log, version }) {
21
+ const cwd = process.cwd();
22
+ if (!values.force && fs.existsSync(CONFIG_FILE)) {
23
+ throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
24
+ }
25
+ if (!values.yes && !process.stdin.isTTY) {
26
+ throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
27
+ }
28
+
29
+ const detected = await detectProjectDefaults(cwd);
30
+ const defaults = defaultsFromFlags(detected, flags);
31
+ const answers = values.yes ? defaults : await askAnswers(defaults, log);
32
+ const cleaned = validatedAnswers(answers, log);
33
+ // Validate what will be written before touching an existing config file.
34
+ resolveConfig(configValuesFromAnswers(cleaned));
35
+
36
+ await writeConfigFile({
37
+ dir: cwd,
38
+ contents: renderConfigFile(cleaned),
39
+ force: values.force === true,
40
+ });
41
+ log.success(`Wrote ${CONFIG_FILE}`);
42
+ if (detected.reactSnap) printReactSnapMapping(detected.reactSnap, log);
43
+ printNextSteps({ detected, log, tag: version.includes('-') ? '@next' : '' });
44
+ }
45
+
46
+ // Turns the flags the CLI already understands into the wizard's starting answers.
47
+ function defaultsFromFlags(detected, flags) {
48
+ const mapped = detected.reactSnap?.values ?? {};
49
+ // Options the wizard renders itself stay out of the generic list.
50
+ const handled = new Set(['sourceDir', 'include', 'crawl', 'csp', 'externalScripts']);
51
+ const extraOptions = Object.fromEntries(
52
+ Object.entries(mapped).filter(([key]) => !handled.has(key) && key !== 'criticalCssPreload'),
53
+ );
54
+ return {
55
+ ...mapped,
56
+ sourceDir: flags.sourceDir ?? mapped.sourceDir ?? detected.sourceDir,
57
+ base: flags.base ?? detected.base ?? '/',
58
+ include: includeList(flags.include ?? mapped.include),
59
+ crawl: flags.crawl ?? mapped.crawl ?? true,
60
+ siteUrl: flags.metadata?.siteUrl ?? '',
61
+ siteName: flags.metadata?.siteName ?? '',
62
+ titleTemplate: flags.metadata?.titleTemplate ?? '',
63
+ defaultImage: flags.metadata?.defaultImage ?? '',
64
+ trailingSlash: flags.metadata?.trailingSlash ?? '',
65
+ csp: flags.csp ?? 'off',
66
+ externalScripts: flags.externalScripts,
67
+ criticalCssPreload: flags.criticalCssPreload ?? mapped.criticalCssPreload,
68
+ extraOptions,
69
+ };
70
+ }
71
+
72
+ // Asks every question in order, closing the terminal interface whatever the outcome.
73
+ async function askAnswers(defaults, log) {
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
75
+ try {
76
+ log.info('A few questions, then a config file with only the options you set.');
77
+ return {
78
+ sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
79
+ base: await ask(rl, 'Public base path', defaults.base),
80
+ include: parseRouteList(
81
+ await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
82
+ ),
83
+ crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
84
+ siteUrl: await ask(
85
+ rl,
86
+ 'Public site URL for canonical and og:url, blank to skip',
87
+ defaults.siteUrl,
88
+ ),
89
+ siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
90
+ titleTemplate: await ask(
91
+ rl,
92
+ "Title template such as '%s | Example', blank to skip",
93
+ defaults.titleTemplate,
94
+ ),
95
+ csp: (await askYesNo(
96
+ rl,
97
+ "Serve a strict CSP (script-src 'self', no inline scripts)?",
98
+ defaults.csp === 'strict' || defaults.csp === 'hash',
99
+ ))
100
+ ? defaults.csp === 'hash'
101
+ ? 'hash'
102
+ : 'strict'
103
+ : 'off',
104
+ defaultImage: defaults.defaultImage,
105
+ trailingSlash: defaults.trailingSlash,
106
+ extraOptions: defaults.extraOptions ?? {},
107
+ };
108
+ } finally {
109
+ rl.close();
110
+ }
111
+ }
112
+
113
+ // Asks one question, showing the value Enter will accept.
114
+ async function ask(rl, question, fallback) {
115
+ const answer = (await rl.question(`${question} (${fallback}): `)).trim();
116
+ return answer.length > 0 ? answer : fallback;
117
+ }
118
+
119
+ // Asks a yes/no question, treating Enter as the default.
120
+ async function askYesNo(rl, question, fallback) {
121
+ const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
122
+ .trim()
123
+ .toLowerCase();
124
+ if (answer.length === 0) return fallback;
125
+ return answer === 'y' || answer === 'yes';
126
+ }
127
+
128
+ // Drops values that would make a broken config, so a typo becomes a warning instead.
129
+ function validatedAnswers(answers, log) {
130
+ const cleaned = { ...answers };
131
+ if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
132
+ log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
133
+ cleaned.siteUrl = '';
134
+ }
135
+ if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
136
+ log.warn('ignoring the title template: it has no %s placeholder');
137
+ cleaned.titleTemplate = '';
138
+ }
139
+ cleaned.include = parseRouteList(
140
+ Array.isArray(cleaned.include) ? cleaned.include.join(',') : (cleaned.include ?? '/'),
141
+ );
142
+ return cleaned;
143
+ }
144
+
145
+ // Accepts the array, comma-separated string or absent include value from every source.
146
+ function includeList(value) {
147
+ if (Array.isArray(value)) return value;
148
+ if (typeof value === 'string') return parseRouteList(value);
149
+ return ['/'];
150
+ }
151
+
152
+ // Prints what the wizard carried over from a legacy react-snap block, and what it did not.
153
+ function printReactSnapMapping(reactSnap, log) {
154
+ log.info('');
155
+ log.info('react-snap migration:');
156
+ if (reactSnap.mapping.length === 0) log.info(' no options carried over');
157
+ for (const { from, to, value } of reactSnap.mapping) {
158
+ log.info(` reactSnap.${from} -> ${to} = ${JSON.stringify(value)}`);
159
+ }
160
+ for (const { key, reason } of reactSnap.ignored) {
161
+ log.warn(` ignored reactSnap.${key}: ${reason}`);
162
+ }
163
+ for (const key of reactSnap.unknown) {
164
+ log.warn(` unknown reactSnap.${key} was not migrated`);
165
+ }
166
+ }
167
+
168
+ // Prints what to do next: install if needed, run it, and where the head layer goes.
169
+ function printNextSteps({ detected, log, tag = '' }) {
170
+ const run = runnerCommand(detected.runner, PACKAGE_NAME);
171
+ const install = installCommand(detected.runner, tag);
172
+ log.info('');
173
+ log.info('Next steps:');
174
+ if (!detected.installed) log.info(` Install ${install}`);
175
+ log.info(` Dry run ${run} --dry-run`);
176
+ log.info(` Prerender ${run}`);
177
+ log.info('');
178
+ log.info('Per-route titles and meta tags come from the head layer:');
179
+ log.info(" import { Head } from 'snappy-prerender/head';");
180
+ log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
181
+ if (detected.usesVite) {
182
+ log.info('');
183
+ log.info('Or run it inside the build (vite.config.js):');
184
+ log.info(" import snappy from 'snappy-prerender';");
185
+ log.info(' plugins: [react(), snappy()],');
186
+ log.info(
187
+ ` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
188
+ );
189
+ }
190
+ }
191
+
192
+ // Builds the command that runs a locally installed binary for the project's package runner.
193
+ function runnerCommand(runner, binary) {
194
+ if (runner === 'pnpm') return `pnpm exec ${binary}`;
195
+ if (runner === 'yarn') return `yarn ${binary}`;
196
+ return `npx ${binary}`;
197
+ }
198
+
199
+ // Builds the command that installs this package as a dev dependency.
200
+ function installCommand(runner, tag = '') {
201
+ const pkg = `${PACKAGE_NAME}${tag}`;
202
+ if (runner === 'pnpm') return `pnpm add -D ${pkg}`;
203
+ if (runner === 'yarn') return `yarn add -D ${pkg}`;
204
+ return `npm install --save-dev ${pkg}`;
205
+ }
package/src/cli.js CHANGED
@@ -1,17 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import process from 'node:process';
4
- import { createInterface } from 'node:readline/promises';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
- import {
8
- CONFIG_FILE,
9
- detectProjectDefaults,
10
- PACKAGE_NAME,
11
- parseRouteList,
12
- renderConfigFile,
13
- writeConfigFile,
14
- } from './core/init.js';
7
+ import { runInit } from './cli-init.js';
15
8
  import { createLogger } from './core/log.js';
16
9
  import { prerender } from './core/run.js';
17
10
 
@@ -66,6 +59,20 @@ Options:
66
59
  --scroll Scroll through each page to trigger lazy content
67
60
  --flat Write about.html instead of about/index.html
68
61
  --not-found <route> Route emitted as 404.html (default: /404)
62
+ --routes-file <file> Read route seeds from a text/JSON file
63
+ --routes-from-sitemap <path-or-url> Seed routes from a local or remote sitemap
64
+ --route-priority <patterns> Patterns ranked first when crawling, comma separated
65
+ --report <file> Write a machine-readable run report (JSON)
66
+ --audit Check the written site for broken links and SEO issues (default: on)
67
+ --no-audit Skip the site audit
68
+ --fail-on-audit-error Fail when the audit finds an error
69
+ --audit-ignore-links <patterns> Link patterns excluded from the audit, comma separated
70
+ --preload-links Inject module, font and LCP image preload hints into each page
71
+ --image-sitemap List page images in the sitemap
72
+ --framework <name> auto | react | vue | svelte | preact (default: auto)
73
+ --retries <n> Retry a failed render this many times (default: 0)
74
+ --retry-delay <ms> Wait between render attempts (default: 500)
75
+ --trace-on-error Write a Playwright trace for the final failed attempt
69
76
  --destination <dir> Write output to another directory (default: in place)
70
77
  --clean-destination Empty the destination before copying the build output
71
78
  --save-as <format> html | png | jpeg (default: html)
@@ -92,7 +99,7 @@ Options:
92
99
  --remove-styles Strip every style tag from the output
93
100
  --async-scripts Mark external scripts async
94
101
  --external-scripts Write injected state scripts as same-origin files, for a strict CSP
95
- --csp <mode> off | strict: strict makes the output work under script-src 'self'
102
+ --csp <mode> off | strict | hash: strict for script-src 'self', hash for inline hashes
96
103
  --no-remove-blobs Keep blob stylesheet links instead of dropping them
97
104
  --remove-attributes <names> Drop these attributes from the output, comma separated
98
105
  --no-strip-build-origin-metadata Keep tags that point at the prerender server
@@ -165,6 +172,20 @@ async function main() {
165
172
  scroll: { type: 'boolean' },
166
173
  flat: { type: 'boolean' },
167
174
  'not-found': { type: 'string' },
175
+ 'routes-file': { type: 'string' },
176
+ 'routes-from-sitemap': { type: 'string' },
177
+ 'route-priority': { type: 'string', multiple: true },
178
+ report: { type: 'string' },
179
+ audit: { type: 'boolean' },
180
+ 'no-audit': { type: 'boolean' },
181
+ 'fail-on-audit-error': { type: 'boolean' },
182
+ 'audit-ignore-links': { type: 'string', multiple: true },
183
+ 'preload-links': { type: 'boolean' },
184
+ 'image-sitemap': { type: 'boolean' },
185
+ framework: { type: 'string' },
186
+ retries: { type: 'string' },
187
+ 'retry-delay': { type: 'string' },
188
+ 'trace-on-error': { type: 'boolean' },
168
189
  destination: { type: 'string' },
169
190
  'clean-destination': { type: 'boolean' },
170
191
  'save-as': { type: 'string' },
@@ -219,7 +240,12 @@ async function main() {
219
240
  return;
220
241
  }
221
242
  if (parsed.positionals[0] === 'init') {
222
- await runInit(parsed);
243
+ await runInit({
244
+ values: parsed.values,
245
+ flags: cliOptions({ values: parsed.values, positionals: [] }),
246
+ log: createLogger(parsed.values['log-level'] ?? 'info'),
247
+ version: readVersion(),
248
+ });
223
249
  return;
224
250
  }
225
251
 
@@ -307,6 +333,26 @@ function cliOptions({ values, positionals }) {
307
333
  if (values.scroll) options.scrollToBottom = true;
308
334
  if (values.flat) options.flatOutput = true;
309
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;
310
356
  if (values.destination !== undefined) options.destination = values.destination;
311
357
  if (values['clean-destination']) options.cleanDestination = true;
312
358
  if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
@@ -354,162 +400,6 @@ function splitList(entries) {
354
400
  return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
355
401
  }
356
402
 
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
403
  // Collects the metadata flags, or null when none was passed so a config file value survives.
514
404
  function metadataOptions(values) {
515
405
  const metadata = {};
@@ -556,7 +446,22 @@ async function loadConfigFile(explicitPath) {
556
446
  `Config file must export a plain options object or a function returning one: ${explicitPath}`,
557
447
  );
558
448
  }
559
- return loaded;
449
+ return resolveSourcePaths(loaded, path.dirname(path.resolve(explicitPath)));
450
+ }
451
+
452
+ // Resolves local route-source paths against the config file, not the process cwd, while
453
+ // leaving URLs and absolute paths alone.
454
+ function resolveSourcePaths(options, dir) {
455
+ const resolve = (value) => {
456
+ if (typeof value !== 'string' || value.length === 0) return value;
457
+ if (/^https?:\/\//i.test(value) || path.isAbsolute(value)) return value;
458
+ return path.resolve(dir, value);
459
+ };
460
+ return {
461
+ ...options,
462
+ routesFile: resolve(options.routesFile),
463
+ routesFromSitemap: resolve(options.routesFromSitemap),
464
+ };
560
465
  }
561
466
 
562
467
  // Reads the package version without importing JSON, keeping Node 20 compatible.
@@ -69,6 +69,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
69
69
  routes: sitemap.routes,
70
70
  robotsUpdated: patched !== null,
71
71
  files: names,
72
+ locs: sitemap.locs,
72
73
  };
73
74
  log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
74
75
  return;
@@ -89,6 +90,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
89
90
  routes: sitemap.routes,
90
91
  robotsUpdated: patched !== null,
91
92
  files: names,
93
+ locs: sitemap.locs,
92
94
  };
93
95
  log.info(
94
96
  `${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,