snappy-prerender 2.6.0-rc.3 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +149 -107
  3. package/docs/limitations.md +58 -0
  4. package/docs/react-snap-migration.md +45 -0
  5. package/docs/report.md +102 -0
  6. package/package.json +9 -5
  7. package/src/cli-init.js +205 -0
  8. package/src/cli.js +159 -168
  9. package/src/core/artifacts.js +112 -0
  10. package/src/core/audit/index.js +114 -0
  11. package/src/core/audit/links.js +63 -0
  12. package/src/core/audit/metadata.js +88 -0
  13. package/src/core/audit/sitemap.js +39 -0
  14. package/src/core/blocking.js +53 -1
  15. package/src/core/browser.js +109 -39
  16. package/src/core/build-origin.js +19 -0
  17. package/src/core/config.js +256 -6
  18. package/src/core/crawl.js +300 -0
  19. package/src/core/csp.js +127 -0
  20. package/src/core/css-url.js +34 -0
  21. package/src/core/destination.js +28 -3
  22. package/src/core/frameworks.js +94 -0
  23. package/src/core/hydration-diff.js +93 -0
  24. package/src/core/init.js +187 -31
  25. package/src/core/inline-css.js +34 -13
  26. package/src/core/metadata-origin.js +11 -2
  27. package/src/core/normalize.js +59 -2
  28. package/src/core/output.js +32 -9
  29. package/src/core/page-content.js +13 -0
  30. package/src/core/page-meta.js +108 -0
  31. package/src/core/preload-links.js +120 -0
  32. package/src/core/preload-manifest.js +61 -7
  33. package/src/core/prune.js +65 -0
  34. package/src/core/render.js +147 -11
  35. package/src/core/report.js +108 -0
  36. package/src/core/resources.js +6 -1
  37. package/src/core/route-config.js +17 -0
  38. package/src/core/route-sources.js +209 -0
  39. package/src/core/routes.js +9 -0
  40. package/src/core/run.js +186 -358
  41. package/src/core/server.js +30 -4
  42. package/src/core/sitemap.js +128 -20
  43. package/src/core/url-policy.js +32 -0
  44. package/src/core/verify.js +103 -49
  45. package/src/core/write.js +363 -0
  46. package/src/head/index.js +2 -2
  47. package/src/head/manager.js +54 -13
  48. package/src/head/tags.js +71 -32
@@ -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-rc.3",
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",
@@ -26,7 +26,7 @@
26
26
  "playwright"
27
27
  ],
28
28
  "engines": {
29
- "node": ">=20.19.0"
29
+ "node": ">=22.12.0"
30
30
  },
31
31
  "main": "./src/index.js",
32
32
  "exports": {
@@ -36,14 +36,18 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "bin": {
39
- "snappy-prerender": "./src/cli.js"
39
+ "snappy-prerender": "src/cli.js"
40
40
  },
41
41
  "files": [
42
42
  "src",
43
+ "docs",
44
+ "CHANGELOG.md",
43
45
  "LICENSE",
44
46
  "README.md"
45
47
  ],
46
- "sideEffects": false,
48
+ "sideEffects": [
49
+ "./src/head/manager.js"
50
+ ],
47
51
  "scripts": {
48
52
  "test": "node scripts/run-tests.mjs tests/unit",
49
53
  "test:integration": "node scripts/run-tests.mjs tests/integration",
@@ -65,7 +69,7 @@
65
69
  "beasties": "0.4.1",
66
70
  "react": "19.2.0",
67
71
  "react-dom": "19.2.0",
68
- "vite": "7.3.6"
72
+ "vite": "7.3.7"
69
73
  },
70
74
  "workspaces": [
71
75
  "tests/fixtures/react16-app",
@@ -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
+ }