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
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # snappy-prerender
2
2
 
3
- **Prerender your single-page app without rewriting it.** Point it at your build output, and every route is rendered in a real browser and written to disk as static HTML — content, titles, meta tags, canonical links and Open Graph tags included. Search engines and social crawlers get the full page; your users get the same app they had before.
3
+ Render a single-page app to static HTML at build time. Point the tool at the build output; it renders each route in Chrome, writes one HTML file per route, and leaves the app unchanged. The output includes page content, titles, meta tags, canonical links and Open Graph tags.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
7
7
  [![node](https://img.shields.io/node/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
8
8
  [![license](https://img.shields.io/npm/l/snappy-prerender.svg)](https://github.com/sayakmukherjee080/snappy-prerender/blob/main/LICENSE)
9
9
 
10
- Package page: [npmjs.com/package/snappy-prerender](https://www.npmjs.com/package/snappy-prerender). While 2.6.0 is a release candidate it is published under the `next` tag, so install with `npm install --save-dev snappy-prerender@next`; the plain command below works once 2.6.0 is stable.
10
+ Package page: [npmjs.com/package/snappy-prerender](https://www.npmjs.com/package/snappy-prerender). Install with `npm install --save-dev snappy-prerender`. Release notes are in [CHANGELOG.md](CHANGELOG.md).
11
11
 
12
- - **No SSR entry, no framework migration, no app changes.** If it builds to a `dist` directory, it can be prerendered.
13
- - **React 16.8, 17, 18 and 19, Vite 6/7/8, or any static build.**
14
- - **Inspired by [react-snap](https://github.com/stereobooster/react-snap)** and rebuilt from scratch for current toolchains: a browser-based prerenderer with a hydration report attached, not another SSR framework.
12
+ - No SSR entry, framework migration or app changes. If the app builds to a directory, it can be prerendered.
13
+ - React 16.8, 17, 18 and 19, Vite 6/7/8, or any static build.
14
+ - Based on the react-snap approach, reimplemented for current toolchains with Playwright and a hydration check.
15
15
 
16
16
  ## Who this is for
17
17
 
18
- **Use it when** your app renders in the browser and you want the crawler-visible HTML to match: Create React App, Vite + React, plain SPAs, CMS-driven sites, marketing pages behind a client router.
18
+ Use it for client-rendered apps that need crawler-visible HTML: Create React App, Vite + React, other SPAs, CMS-backed sites and client-routed marketing pages.
19
19
 
20
- **Skip it when** you already render HTML on the server — Next.js, Remix, Astro and friends do not need prerendering, and this tool will not improve on what they ship.
20
+ Do not use it for apps that already render HTML on the server, such as Next.js, Remix or Astro.
21
21
 
22
22
  ## Quick start
23
23
 
@@ -74,47 +74,48 @@ import { prerender } from 'snappy-prerender/core';
74
74
 
75
75
  const report = await prerender({ sourceDir: 'dist', include: ['/'] });
76
76
  if (!report.ok) {
77
- for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
77
+ for (const failure of report.verification?.routes.filter((entry) => !entry.ok) ?? []) {
78
78
  console.error(failure.route, failure.hydrationErrors);
79
79
  }
80
+ for (const failure of report.errors) console.error(failure.route, failure.message);
80
81
  }
81
82
  ```
82
83
 
83
- ## Inspired by react-snap
84
+ ## Based on react-snap
84
85
 
85
- react-snap proved the idea that makes this approach attractive: you do not need a server, a second renderer or a rewrite to give crawlers real HTML — you need a browser, a router and a build step. It carried a generation of webpack-era SPAs and has been unmaintained since 2019.
86
+ [react-snap](https://github.com/stereobooster/react-snap) showed that a browser and a build step can produce crawler-readable HTML without a server renderer. It has been unmaintained since 2019.
86
87
 
87
- `snappy-prerender` is a fresh implementation of that idea for current toolchains. It is not a fork: no react-snap code was reused. What changed is the machinery around the same core trick — Playwright instead of an old Puppeteer, Chrome or Edge from the machine you already have, Vite plugin support, a head and metadata layer with client-side updates, hydration verification that tells you how each page booted, and option names that say what they do.
88
+ snappy-prerender reimplements that approach; no react-snap code is reused. It uses Playwright, resolves an installed Chrome or Edge, supports Vite, ships a head layer, and verifies hydration.
88
89
 
89
- If you are moving over, the [differences and migration table](#differences-from-react-snap) maps every option.
90
+ If you are moving over, [Migrating from react-snap](docs/react-snap-migration.md) maps the options.
90
91
 
91
92
  ## What you get
92
93
 
93
94
  | | |
94
95
  | --- | --- |
95
- | **Static HTML per route** | `route/index.html` (or `route.html` with `flatOutput`), plus a `404.html` from your not-found route. Identical files are left untouched, so rebuilds and CI caches stay stable. |
96
- | **Head and metadata** | A `Head` component writes per-route title, description, robots, canonical, Open Graph and Twitter tags — and keeps updating them on client-side navigation, exactly like a page-per-request site. |
97
- | **Hydration report** | Every written page is reloaded with the real bundle and reported as `hydrated`, `re-rendered`, `undetected` or `unknown`, with React mismatch messages per route. |
98
- | **A ready contract** | `window.__prerenderReady`, a selector, or network quiescence — whichever fits your app. |
99
- | **Output optimisation** | Critical CSS, HTML/CSS minification, preconnect hints, image preload, a preload manifest, script and style surgery. |
100
- | **CSS-in-JS support** | CSSOM-only styles (emotion, styled-components in speedy mode) and constructable stylesheets are folded back into the HTML. |
101
- | **Ajax state replay** | Captured JSON and `window.snapSaveState()` are injected before the first script, so hydration sees the same data the prerender did. |
102
- | **Guided setup** | `snappy-prerender init` asks the handful of questions that matter — build directory, base path, routes, site URL, site name, title template — and writes a `snappy.config.js` with your answers, detected from the project where it can. |
103
- | **Any static build** | Base paths, `serveCmd` for apps that need a live API, screenshots, dry runs, bounded concurrency. |
96
+ | **Static HTML per route** | `route/index.html` (or `route.html` with `flatOutput`), plus `404.html`. Identical files are left untouched. |
97
+ | **Head and metadata** | Per-route title, description, robots, canonical, Open Graph and Twitter tags, updated on client-side navigation. |
98
+ | **Hydration report** | Each route is reloaded with the real bundle and reported as `hydrated`, `re-rendered`, `undetected` or `unknown`, with mismatch messages. |
99
+ | **Ready contract** | `window.__prerenderReady`, a selector, or network quiescence. |
100
+ | **Output optimisation** | Critical CSS, HTML/CSS minification, preconnect hints, image preload, a preload manifest, script and style removal. |
101
+ | **CSS-in-JS support** | CSSOM-only styles and constructable stylesheets are written back into the HTML. |
102
+ | **Ajax state replay** | Captured JSON and `window.snapSaveState()` are injected before the first script. |
103
+ | **Guided setup** | `snappy-prerender init` writes a `snappy.config.js` from the project settings and a few answers. |
104
+ | **Any static build** | Base paths, `serveCmd`, screenshots, dry runs and bounded concurrency. |
104
105
 
105
106
  ## How it works
106
107
 
107
- 1. Serves the built output on an ephemeral loopback port (or copies it to `destination` first).
108
- 2. Crawls same-origin links from the seed routes, rendering each page in a fresh browser context.
109
- 3. Waits for the app to settle: ready contract, quiet network, fonts, two animation frames.
110
- 4. Freezes CSS animations, optionally blocks third-party requests, records the resources the page used, and reconciles any head tags the app rendered.
111
- 5. Serialises the DOM, then post-processes it: removes the freeze style and `data-prerender-remove` elements, de-duplicates head styles, drops dead blob stylesheets, adds link hints, optionally inlines CSS, removes or marks scripts, optionally minifies, and notes metadata that points at the build server or ships more than one title.
112
- 6. Writes `route/index.html` (or a screenshot), leaving identical files untouched.
113
- 7. Reloads every written file with the real client bundle, reports React hydration errors, and records whether each route hydrated or re-rendered.
108
+ 1. Serves the built output on an ephemeral loopback port, or copies it to `destination`.
109
+ 2. Crawls same-origin links from the seed routes in a fresh browser context per page.
110
+ 3. Waits for the ready contract, quiet network, fonts and two animation frames.
111
+ 4. Freezes CSS animations, optionally blocks third-party requests, records resources and reconciles head tags.
112
+ 5. Serialises the DOM and post-processes it: removes freeze styles and `data-prerender-remove` elements, de-duplicates head styles, drops dead blob stylesheets, adds link hints, optionally inlines CSS, removes or marks scripts, optionally minifies, and records metadata issues.
113
+ 6. Writes `route/index.html` or a screenshot, leaving identical files untouched.
114
+ 7. Reloads each written file with the client bundle and reports hydration errors and boot mode.
114
115
 
115
116
  ## Head and metadata
116
117
 
117
- Per-route titles, descriptions, canonical links and Open Graph/Twitter tags come from a `Head` component the package ships. It writes into the document head while the route is mounted and reconciles on every render, so client-side navigation updates the head exactly like a site that renders a page per request — and the prerenderer captures whatever is there, because the head is part of the page it serialises.
118
+ Per-route titles, descriptions, canonical links and Open Graph/Twitter tags come from the package's `Head` component. It updates the document head on every render, including client-side navigation, and the prerenderer captures the result.
118
119
 
119
120
  ```jsx
120
121
  import { Head } from 'snappy-prerender/head';
@@ -126,11 +127,18 @@ import { Head } from 'snappy-prerender/head';
126
127
  type="article"
127
128
  locale="en_GB"
128
129
  robots="index,follow"
130
+ lang="en"
131
+ dir="ltr"
132
+ structuredData={{ '@context': 'https://schema.org', '@type': 'WebPage', name: 'Awards' }}
133
+ alternates={[
134
+ { hreflang: 'en', href: '/awards' },
135
+ { hreflang: 'x-default', href: '/awards' },
136
+ ]}
129
137
  extra={[{ name: 'keywords', content: 'polymer' }, { rel: 'alternate', href: '/feed.xml' }]}
130
138
  />
131
139
  ```
132
140
 
133
- Project-wide values come from the `metadata` option. The prerenderer injects them into every page, so the component composes absolute URLs from the public site rather than the build server:
141
+ Project-wide values come from the `metadata` option. The prerenderer injects them into every page, so the component builds absolute URLs from the public site instead of the build server:
134
142
 
135
143
  ```js
136
144
  snappy({
@@ -146,42 +154,68 @@ snappy({
146
154
 
147
155
  What it emits, per route: `title` (with the template applied unless the title already carries its suffix), `meta[name=description]`, `meta[name=robots]`, `link[rel=canonical]`, `og:title`, `og:description`, `og:type`, `og:url`, `og:image`, `og:site_name`, `og:locale`, and `twitter:card` / `twitter:title` / `twitter:description` / `twitter:image`. Switch a family off with `openGraph: false` or `twitter: false`, and skip the canonical with `canonical: false`.
148
156
 
149
- Rules worth knowing:
150
-
151
- - `metadata` values are defaults; a `Head`'s own props win. When several Heads render, the later one in render order wins per tag, so a page overrides its layout.
152
- - Tags are matched on the attribute a crawler reads, so an existing tag is updated in place rather than duplicated. Two tags with the same identity already in the document — a `name`, `property`, `http-equiv` or `rel` match — are reduced to one.
153
- - A tag the head layer generated carries `data-snappy-head-created` beside `data-snappy-head`, so a later page load can tell it apart from one the app wrote.
154
- - When no Head wants a tag any more, a generated one is removed and one the app wrote is restored to the value it had before the head layer touched it. That original is recorded into the page during prerendering, so a route change brings the template's fallback metadata back.
155
- - The component renders nothing, so it cannot affect hydration of the page content.
156
- - The title template applies unless the title already ends with the template's fixed part, or is the site name itself, so a title composed by hand is never suffixed twice.
157
- - Absolute URLs are required for `og:url`, `og:image` and canonical. Without `metadata.siteUrl` they fall back to the browser origin, which during prerendering is the build server — the run warns when that happens. The `trailingSlash` convention applies to page URLs only; asset URLs are left alone.
158
- - `metadata.base` overrides the base path used for absolute URLs and defaults to the `base` option.
159
- - On React 19, React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template tag alone, so a page can end up with two titles. Use React's native metadata or this component, not both; the run warns when the output carries more than one `<title>`.
157
+ Rules:
158
+ - `metadata` values are defaults; a `Head` prop wins. When several Heads render, the last one wins per tag.
159
+ - Tags are matched by the attribute a crawler reads, so existing tags are updated instead of duplicated. Duplicate identities are reduced to one.
160
+ - Generated tags carry `data-snappy-head-created` beside `data-snappy-head`, so a later page load can tell them from tags the app wrote.
161
+ - When no Head wants a tag, a generated one is removed and an app-written one is restored to its previous value. That original is recorded in the page during prerendering.
162
+ - The component renders nothing, so it does not affect hydration of page content.
163
+ - The title template is skipped when the title already ends with its fixed part, or is the site name, so a hand-built title is not suffixed twice.
164
+ - `structuredData` emits `<script type="application/ld+json">` for one object or an array, keyed per mounted `Head`. Values are escaped; add `@context` when needed.
165
+ - `lang` and `dir` are written on `<html>`. Later Heads win, and the original values return when no Head wants them.
166
+ - `alternates` emits `<link rel="alternate" hreflang="...">` with absolute URLs using the same `siteUrl`/`base`/`trailingSlash` policy as canonical links. The same alternates feed sitemap `xhtml:link` entries.
167
+ - `og:url`, `og:image` and canonical need absolute URLs. `metadata.siteUrl` must be an origin; put a sub-path deployment in `base`. Without `siteUrl`, build-server URLs are removed by default and the run warns. `trailingSlash` applies to page URLs only. Canonical, social and sitemap URLs share the page-path policy: directory output keeps its slash, flat output stays extensionless, and `trailingSlash` wins.
168
+ - `metadata.base` overrides the base path for absolute URLs and defaults to `base`.
169
+ - On React 19, React also hoists `<title>` and `<meta>` from JSX and leaves an existing template title alone, which can produce two titles. Use one metadata system; the run warns when a page has more than one `<title>`.
160
170
  - Every metadata value has a CLI flag: `--metadata-site-url`, `--metadata-site-name`, `--metadata-title-template`, `--metadata-default-image`, `--metadata-trailing-slash`.
161
- - `react` is an optional peer dependency, needed only for this entry point. Non-React code can call `setHead(id, props)` and `clearHead(id)` instead.
171
+ - `react` is an optional peer dependency for this entry point. Non-React code can call `setHead(id, props)` and `clearHead(id)`.
172
+
173
+ ## Audit, reports and route sources
174
+
175
+ The audit runs by default and reports:
176
+
177
+ - route errors, truncation, empty or duplicate output, broken internal links;
178
+ - duplicate or missing metadata, canonical mismatches, heading counts, sitemap disagreement and orphan pages;
179
+ - CSP violations reported by the browser.
180
+
181
+ Findings are returned in `report.audit` and logged. They do not fail the run unless `failOnAuditError` is true. `auditIgnoreLinks` excludes dynamic links, and `--no-audit` skips the audit. See [docs/report.md](docs/report.md) for the rule list and JSON schema.
182
+
183
+ `reportPath` (or `--report`) writes the report as versioned JSON. URLs are reduced to origin and path, and the storage-state path is redacted.
184
+
185
+ Routes can come from `include` and crawling, from a text/JSON `routesFile`, or from a local or remote sitemap via `routesFromSitemap`. Relative source paths resolve against the config file. Root-relative sitemap locations have `base` stripped, and a remote sitemap's off-origin locations are ignored. If either source is supplied and `include` is not set, the root is not seeded implicitly. With `maxRoutes` or `routePriority`, crawling runs in deterministic breadth-first waves sorted by priority then path. `truncated.droppedRoutes` lists routes that did not fit.
186
+
187
+ `routeOverrides` applies `timeout`, `waitFor`, `readySelector`, `readyFlag`, `crawl` and `storageState` to matching routes; later entries win per key. `transformHtml` runs once per route after minification and before preload/CSP injection. It may be async and must return a string or nothing.
188
+
189
+ `preloadLinks` injects `<link rel="modulepreload">` for runtime chunks, font preloads with their response content type, and the LCP image. It complements `preloadManifest`: the manifest is for hosts that send Early Hints, while the injected links work on any static host. Script hints are skipped when `removeScriptTags` is on.
190
+
191
+ `csp: 'hash'` adds a `<meta http-equiv="Content-Security-Policy">` with SHA-256 hashes for inline scripts and styles. It implies `externalScripts: true` and no CSS swap handler, like `strict`. The policy allows `'self'` only, so third-party scripts or styles need their own policy; the audit warns when the browser reports a blocked request. An existing CSP meta is kept and reported. Inline `style=""` attributes and event handlers cannot be hashed; the audit warns about the attributes.
162
192
 
163
193
  ## Hydration verification
164
194
 
165
- After writing the output, every route is loaded again with the real client bundle in a fresh browser context. The pass reports two things, and by default neither fails the build: the static HTML that crawlers read is already written and is unaffected by what the client does afterwards. Set `failOnHydrationError: true` (or pass `--fail-on-hydration-error`) when you want hydration treated as a build gate.
195
+ After writing the output, each route is loaded again in a fresh browser context with the client bundle. By default the results are reported but do not fail the build; the static HTML is already written. Set `failOnHydrationError: true` (or pass `--fail-on-hydration-error`) to make hydration a build gate.
196
+
197
+ **Hydration errors.** Mismatch messages for React, Vue, Svelte and Preact are reported with the route, framework and message. React text mismatches and minified production codes (`#418`, `#423`, `#425`) are recognised; Preact uses the React-compatible messages. A mismatch means the client re-renders the page instead of adopting the static markup. The crawler-visible HTML is unchanged.
166
198
 
167
- **Hydration errors.** React mismatch messages, text mismatches and the minified production codes (`#418`, `#423`, `#425`) are reported with the route and the message. They mean the client re-renders that page instead of adopting the markup, which costs the user some work on load but leaves the static output — search engines and social crawlers still read the prerendered HTML. Enforce them with `failOnHydrationError: true` when you want the stricter gate.
199
+ The framework is detected from DOM markers only (`__reactContainer`, `__vue_app__`/`__vue__`, `__k`, `data-svelte-h`); nothing is imported or executed. Set `framework` when a custom root defeats detection. When a route has hydration errors or re-rendered, `firstMismatch` reports the first difference between the written HTML and the live container as `{ path, kind, expected, actual }`, bounded to 5,000 nodes and depth 20.
168
200
 
169
- **How the app booted.** A probe installed before the app's scripts run remembers the first prerendered node in the container and checks whether it survived:
201
+ A probe installed before the app's scripts run records the first prerendered node and checks whether it survived:
170
202
 
171
203
  | Mode | Meaning |
172
204
  | --- | --- |
173
- | `hydrated` | The app adopted the prerendered markup, which is the goal. |
205
+ | `hydrated` | The app kept the prerendered markup. |
174
206
  | `re-rendered` | The app discarded it and rendered from scratch. |
175
- | `undetected` | No React root was found on the container, so the mode could not be judged. |
176
- | `unknown` | The prerendered node was never seen, for example a container outside the usual ids. |
207
+ | `undetected` | No framework root was found on the container. |
208
+ | `unknown` | The prerendered node was not seen, for example a container outside the usual ids. |
177
209
 
178
- `re-rendered` matters because an app calling `createRoot` instead of `hydrateRoot` throws the prerendered DOM away on boot — and because `createRoot` cannot raise a hydration error, nothing else would ever tell you. It is reported per route as `verification.routes[].mode` and summarised in `verification.modes`, with a warning in the log. Set `failOnRerender: true` to make it fail the build as well.
210
+ A verification navigation that returns HTTP 4xx/5xx, or a container with no content, is reported as failed with its `status` or `empty` flag. `undetected` and `unknown` are reported as counts; `failOnUndetected: true` fails the build for them. A verification pass that cannot run is listed in `verification.errors`; `failOnVerificationError: true` fails separately from hydration errors.
179
211
 
180
- **Page errors.** Errors the browser logs while verifying are reported too: uncaught exceptions as warnings, console errors at debug level. Errors thrown during rendering itself are counted in the summary and reported per route, and `failOnPageError: true` turns them into a build failure; by default an app that throws still ships what it managed to render, because the static output is what crawlers read.
212
+ `re-rendered` usually means the app calls `createRoot` instead of `hydrateRoot`. Since `createRoot` does not emit a hydration error, the mode report is the only signal. It is reported per route as `verification.routes[].mode` and summarised in `verification.modes`. Set `failOnRerender: true` to fail the build.
181
213
 
182
- **React 16 and 17.** Hydration and boot modes are judged the same way, and their dev mismatch warnings ("Text content did not match", "Did not expect server HTML to contain") are recognised. Their production builds report nothing for a mismatch at all — no warning, no minified code — so on 16 and 17 the boot-mode report (`hydrated` versus `re-rendered`) is the signal that something drifted.
214
+ **Page errors.** Uncaught browser errors are reported as warnings, and console errors at debug level. Errors thrown during rendering are counted in the summary and reported per route; `failOnPageError: true` makes them fail the build. By default the static HTML is still written.
183
215
 
184
- **Rebuilds.** On a large site most routes are unchanged between builds, and reloading them proves nothing new. `verifyChangedOnly: true` skips the verification pass for routes whose HTML file was left untouched, which roughly halves rebuild time; the report notes how many routes were skipped, and the first run after a change still verifies everything that moved.
216
+ **React 16 and 17.** Hydration and boot modes are judged the same way, and their dev mismatch warnings are recognised. Their production builds report nothing for a mismatch, so the boot mode (`hydrated` versus `re-rendered`) is the signal.
217
+
218
+ **Rebuilds.** `verifyChangedOnly: true` skips verification for routes whose HTML file did not change. The report shows how many routes were skipped.
185
219
 
186
220
  ## Ready contract
187
221
 
@@ -225,7 +259,7 @@ snappy({ inlineCss: true }) // alias for 'inline'
225
259
  npm install --save-dev beasties
226
260
  ```
227
261
 
228
- It inlines the critical CSS and converts the remaining stylesheets to non-blocking loads using `media="print"` plus an inline `onload` handler, with a `<noscript>` fallback. Note that the inline handler is inline JavaScript: a strict `script-src` CSP without `unsafe-inline` will leave the deferred stylesheets unapplied. In that case use `'inline'` or a nonce-aware setup.
262
+ It inlines the critical CSS and converts the remaining stylesheets to non-blocking loads using `media="print"` plus an inline `onload` handler, with a `<noscript>` fallback. The inline handler is inline JavaScript, so a strict `script-src` without `unsafe-inline` leaves the deferred stylesheets unapplied. Use `'inline'` or a nonce-aware setup in that case.
229
263
 
230
264
  ### Minification
231
265
 
@@ -233,7 +267,7 @@ It inlines the critical CSS and converts the remaining stylesheets to non-blocki
233
267
  snappy({ minifyHtml: true, minifyCss: true });
234
268
  ```
235
269
 
236
- `minifyHtml` uses html-minifier-terser with react-snap's defaults (whitespace collapsing, boolean attribute collapsing, attribute sorting). **Whitespace collapsing changes text nodes, which React notices during hydration** — the verifier is your safety net, and `{ collapseWhitespace: false }` is the usual fix. `minifyCss` is passed through to clean-css and also minifies CSS inlined into the HTML.
270
+ `minifyHtml` uses html-minifier-terser with react-snap's defaults (whitespace collapsing, boolean attribute collapsing, attribute sorting). **Whitespace collapsing changes text nodes, which React detects during hydration.** Use `{ collapseWhitespace: false }` if the verifier reports mismatches. `minifyCss` is passed to clean-css and also minifies CSS inlined into the HTML.
237
271
 
238
272
  ### Link hints
239
273
 
@@ -241,11 +275,11 @@ snappy({ minifyHtml: true, minifyCss: true });
241
275
  snappy({ preconnectThirdParty: true, preloadImages: true, preloadManifest: true });
242
276
  ```
243
277
 
244
- - `preconnectThirdParty` (default `true`) records every third-party origin the page tried to reach — including ones blocked by `blockThirdParty` — and adds `<link rel="preconnect">` for each.
278
+ - `preconnectThirdParty` (default `true`) records every third-party origin the page tried to reach, including blocked ones, and adds `<link rel="preconnect">` for each.
245
279
  - `preloadImages` adds `<link rel="preload" as="image">` for same-origin images the page loaded.
246
- - `preloadManifest` writes `preload-manifest.json` into the output directory: for each route, a `Link` header value listing the scripts and stylesheets it used, filtered by `ignoreForPreload` (default `['service-worker.js']`). Hints and route keys carry the base path, so serve these as Early Hints or `Link` response headers as they are.
280
+ - `preloadManifest` writes `preload-manifest.json`: per route, a `Link` header value for the scripts and stylesheets the final HTML still references. Entries respect `ignoreForPreload` (default `['service-worker.js']`), inline CSS, tag removal and the base path. Serve them as Early Hints or `Link` headers.
247
281
 
248
- react-snap called this `http2PushManifest`. Browsers removed HTTP/2 push (Chrome in v106), so the manifest is now a header list rather than a push list.
282
+ react-snap called this `http2PushManifest`. Browsers removed HTTP/2 push (Chrome 106), so the manifest is a header list rather than a push list.
249
283
 
250
284
  ### Sitemap
251
285
 
@@ -253,7 +287,7 @@ react-snap called this `http2PushManifest`. Browsers removed HTTP/2 push (Chrome
253
287
  snappy({ sitemap: true, metadata: { siteUrl: 'https://example.com' } });
254
288
  ```
255
289
 
256
- `snappy` already knows every route it rendered, so it can write the sitemap for you. `sitemap.xml` is generated from the output files — a directory route becomes `/about/`, a `flatOutput` route becomes `/about.html` — with the base path applied and the not-found route left out. Each entry carries a `<lastmod>` of the run date.
290
+ The sitemap is generated from the written routes using the same page-path policy as canonical URLs. `noindex` pages and the not-found route are omitted. Above 50,000 URLs it is split behind a sitemap index. Entries have no `<lastmod>`; a build date would claim every page changed on every build.
257
291
 
258
292
  When the output directory already has a `robots.txt`, a `Sitemap:` line is appended to it if it does not point anywhere yet; an existing directive is left alone. Without a `robots.txt`, the run logs the exact line to add instead of creating a file you did not ask for.
259
293
 
@@ -268,7 +302,7 @@ snappy({
268
302
  });
269
303
  ```
270
304
 
271
- The persisted metadata element is exempt from `removeScriptTags`; everything else is removed as asked.
305
+ The persisted metadata element is kept when `removeScriptTags` is on; other scripts are removed.
272
306
 
273
307
  ### Removing volatile markup
274
308
 
@@ -278,9 +312,9 @@ Mark any element with `data-prerender-remove` and it is dropped from the output:
278
312
  <div data-prerender-remove>{ads.render()}</div>
279
313
  ```
280
314
 
281
- Use it for regions that only make sense after the client takes over — ads, chat widgets, anything injected after mount. The rule of thumb: remove markup that React will *not* render on its first client pass. Removing something React does expect causes the very hydration mismatch it was meant to avoid, which is exactly what the verifier would then report.
315
+ Use it for regions that only exist after the client takes over, such as ads or chat widgets. Remove markup that React does not render on its first client pass. Removing markup React expects causes the hydration mismatch the verifier reports.
282
316
 
283
- Some libraries stamp values that change on every page load — antd's `rc-menu` writes `data-menu-id="rc-menu-uuid-…"` — which makes every rebuild rewrite every file and leaves `verifyChangedOnly` nothing to skip. List those attribute names (or RegExps) and they are dropped from the output:
317
+ Some libraries stamp values that change on every load. Ant Design's `rc-menu`, for example, writes `data-menu-id="rc-menu-uuid-…"`, which makes every rebuild rewrite every file and leaves `verifyChangedOnly` nothing to skip. List those attribute names (or RegExps) and they are removed from the output:
284
318
 
285
319
  ```js
286
320
  snappy({ removeAttributes: ['data-menu-id', 'aria-controls'] });
@@ -311,21 +345,21 @@ Two things the dial cannot remove, because they are what prerendered HTML is:
311
345
 
312
346
  ## Third-party requests
313
347
 
314
- Third-party requests are **allowed by default**. Most React sites get their content from a CMS, headless API or a separate service origin, and blocking those during prerendering produces pages that render without data — so the default keeps them working, matching react-snap.
348
+ Third-party requests are allowed by default. Most React sites load content from a CMS, headless API or separate service origin; blocking those during prerendering produces pages without data. The default matches react-snap.
315
349
 
316
350
  ```js
317
- snappy({ blockThirdParty: false }); // default: allow everything
318
- snappy({ blockThirdParty: true }); // abort every third-party request
351
+ snappy({ blockThirdParty: false }); // default: allow all
352
+ snappy({ blockThirdParty: true }); // abort third-party requests
319
353
  snappy({ blockThirdParty: true, allowedHosts: ['cms.example.com'] }); // block all but the CMS
320
354
  ```
321
355
 
322
- Turning blocking on makes rendering deterministic and keeps analytics, ads and chat widgets from executing during a build. `allowedHosts` is the usual setting for a CMS-backed site: the API stays reachable while everything else is cut.
356
+ Blocking makes rendering deterministic and keeps analytics, ads and chat widgets from executing during a build. `allowedHosts` keeps a CMS API reachable while blocking everything else.
323
357
 
324
- Preconnect hints are generated for **every** third-party origin the page contacts — including hosts you allowlist while blocking — so the deployed page can start those connections early. Set `preconnectThirdParty: false` to turn the hints off. The CLI equivalent for blocking is `--block-third-party`.
358
+ Preconnect hints are generated for every third-party origin the page contacts, including allowlisted hosts while blocking. Set `preconnectThirdParty: false` to disable them. The CLI equivalent for blocking is `--block-third-party`.
325
359
 
326
360
  ## CSS-in-JS
327
361
 
328
- Emotion, styled-components, vanilla-extract, stitches, JSS and friends work out of the box as long as their styles end up as text inside a `<style>` tag. Three things need more than plain serialisation:
362
+ Emotion, styled-components, vanilla-extract, stitches and JSS work when their styles end up as text inside a `<style>` tag. Three cases need extra handling:
329
363
 
330
364
  **CSSOM-only styles.** In production, emotion and styled-components v5 switch to `CSSStyleSheet.insertRule` ("speedy" mode), which puts rules in the CSS object model and leaves the style element empty in the DOM. Serialising the page would drop every one of those rules. `captureRuntimeStyles` (default `true`) copies them back into the element text, and also folds document-level constructable stylesheets (`document.adoptedStyleSheets`) into a `<style>` tag. Set it to `false` to leave stylesheets untouched.
331
365
 
@@ -360,7 +394,7 @@ if (cached) return cached;
360
394
  return fetch('/api/items?page=2').then((response) => response.json());
361
395
  ```
362
396
 
363
- `window.snapStore` is keyed by the request path (including its query string), exactly as your app requested it. Responses larger than `maxCachedBytes` (default 5 MiB) are skipped, so one heavy endpoint cannot bloat every page.
397
+ `window.snapStore` is keyed by the request path, including its query string. Responses larger than `maxCachedBytes` (default 5 MiB) are skipped so one large endpoint cannot bloat every page.
364
398
 
365
399
  **App state.** Define `window.snapSaveState` and whatever it returns is injected as globals before the first script:
366
400
 
@@ -429,14 +463,20 @@ The function is awaited once per run, and whatever it returns must be a plain op
429
463
  | --- | --- | --- |
430
464
  | `sourceDir` | `dist` | Built output directory |
431
465
  | `destination` | `null` | Copy the output here and write results here instead of in place |
466
+ | `cleanDestination` | `false` | Empty the destination before copying, so removed assets do not linger |
432
467
  | `base` | `/` | Public base path |
433
- | `include` | `['/']` | Seed routes and allowlist. Wildcards (`/blog/*`) filter discovered routes, only concrete paths are seeded. Supplying your own list switches discovery off, so the listed routes are all that is rendered; leave it unset to render the root and crawl everything linked from it |
468
+ | `include` | `['/']` | Seed routes and output allowlist. Wildcards (`/blog/*`) filter discovered routes, only concrete paths are seeded, and a seed that does not match the list is used for crawling but not written. Passing `['/']` is treated as the default: crawl everything linked from the root |
434
469
  | `exclude` | `[]` | Routes to skip, strings or RegExp |
435
470
  | `crawl` | `true` | Follow same-origin links from seeds |
436
471
  | `maxDepth` | `null` | Crawl depth limit, `null` is unlimited, `0` renders seeds only |
437
- | `maxRoutes` | `null` | Stop after this many routes; hitting the cap marks the run incomplete |
472
+ | `maxRoutes` | `null` | Stop after this many routes; hitting the cap marks the run incomplete and lists the routes that did not fit in `truncated.droppedRoutes` |
473
+ | `routePriority` | `[]` | Patterns ranked first when crawling, strings or RegExp; setting it selects deterministic BFS |
474
+ | `routesFile` | `null` | Text or JSON route seeds, one route per line or `{ "routes": [] }`; a relative path resolves against the config file |
475
+ | `routesFromSitemap` | `null` | Local path or http(s) URL whose `<loc>` entries seed routes; sitemap indexes are followed one level, the public base is stripped from root-relative locs, and off-origin locs are ignored. Absolute locs in a local sitemap need `metadata.siteUrl` or a remote sitemap origin |
476
+ | `routeOverrides` | `[]` | Per-route `{ match, timeout, waitFor, readySelector, readyFlag, crawl, storageState }`; later entries win |
477
+ | `transformHtml` | `null` | Function `(html, { route, file, stats, config })` run before preload/CSP injection; may be async |
438
478
  | `concurrency` | derived | Parallel pages, derived from CPU count, capped at 8 |
439
- | `timeout` | `30000` | Per-route timeout in ms |
479
+ | `timeout` | `30000` | Navigation, ready and network-settle timeout in ms; each wait gets this budget |
440
480
  | `quietPeriod` | `500` | Network must be idle this long before capture |
441
481
  | `readyFlag` | `__prerenderReady` | Window flag awaited when defined |
442
482
  | `readySelector` | `null` | Selector awaited when set |
@@ -447,6 +487,7 @@ The function is awaited once per run, and whatever it returns must be a plain op
447
487
  | `browser` | `auto` | `auto`, `chrome`, `msedge`, `chromium`, or a path to an executable |
448
488
  | `browserDownload` | `true` | Allow the Chrome for Testing fallback download |
449
489
  | `browserDownloadHash` | `null` | SHA-256 digest the fallback download must match, verified before use |
490
+ | `browserDownloadVersion` | `null` | Chrome for Testing build id to use for the cache and download instead of resolving `stable` |
450
491
  | `browserArgs` | `[]` | Extra browser launch arguments |
451
492
  | `headless` | `true` | Run the browser headless |
452
493
  | `ignoreHTTPSErrors` | `false` | Ignore TLS errors while rendering |
@@ -457,38 +498,57 @@ The function is awaited once per run, and whatever it returns must be a plain op
457
498
  | `scrollStepDelay` | `100` | Delay between scroll steps when `scrollToBottom` is on |
458
499
  | `captureRuntimeStyles` | `true` | Fold CSSOM-only styles and constructable stylesheets into the output |
459
500
  | `captureFormState` | `true` | Sync `checked` and `selected` into the output markup |
460
- | `csp` | `'off'` | `'off'` leaves the injected pieces inline; `'strict'` makes the output work under `script-src 'self'` by moving state to a same-origin file and dropping the CSS swap handlers |
501
+ | `csp` | `'off'` | `'off'` leaves the injected pieces inline; `'strict'` makes the output work under `script-src 'self'` by moving state to a same-origin file and dropping the CSS swap handlers; `'hash'` adds a meta policy with SHA-256 hashes for inline scripts and styles |
461
502
  | `inlineCss` | `false` | `'inline'`, `'critical'` (needs beasties) or `true` for `'inline'` |
462
503
  | `criticalCssPreload` | `'media'` | Swap used for CSS deferred by `inlineCss: 'critical'`; `false` emits no swap handlers, for a strict script-src |
463
504
  | `minifyHtml` | `false` | Minify output HTML, `true` for defaults or an html-minifier-terser options object |
464
505
  | `minifyCss` | `false` | Minify CSS with clean-css, also applied to inlined CSS |
465
506
  | `preconnectThirdParty` | `true` | Add preconnect hints for every third-party origin the page contacts |
466
507
  | `preloadImages` | `false` | Add preload hints for same-origin images |
467
- | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints |
468
- | `ignoreForPreload` | `['service-worker.js']` | File names excluded from the manifest |
469
- | `sitemap` | `false` | Write `sitemap.xml` from the rendered routes and point an existing `robots.txt` at it; needs `metadata.siteUrl` |
508
+ | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints, derived from the final HTML |
509
+ | `preloadLinks` | `false` | Inject module, font and LCP image preload hints for static hosts; script hints are skipped when `removeScriptTags` is on |
510
+ | `ignoreForPreload` | `['service-worker.js']` | File names excluded from the manifest and injected hints |
511
+ | `sitemap` | `false` | Write `sitemap.xml` (or a sitemap index above 50,000 URLs) from the routes that do not declare noindex, and point an existing `robots.txt` at it; needs `metadata.siteUrl` |
512
+ | `imageSitemap` | `false` | List same-origin page images in the sitemap |
470
513
  | `cacheAjaxRequests` | `false` | Expose captured JSON responses as `window.snapStore` |
471
- | `maxCachedBytes` | `5242880` | Largest JSON response cached per route, measured on the serialised body |
514
+ | `maxCachedBytes` | `5242880` | Largest JSON response cached per request, measured on the serialised body |
472
515
  | `removeBlobs` | `true` | Drop stylesheet links pointing at dead `blob:` URLs |
473
516
  | `removeAttributes` | `[]` | Attribute names or RegExps to drop from the output, for libraries that stamp per-load ids |
474
- | `removeStyleTags` | `false` | Strip every style tag from the output |
517
+ | `stripBuildOriginMetadata` | `true` | Remove canonical and social tags whose URL points at the prerender server |
518
+ | `failOnBuildOriginMetadata` | `false` | Fail when metadata still points at the prerender server after stripping |
519
+ | `pruneStaleRoutes` | `false` | Delete pages listed by the previous run's manifest that this run no longer writes |
520
+ | `removeStyleTags` | `false` | Strip every style tag from the output; cannot be combined with `inlineCss` `'inline'` |
475
521
  | `removeScriptTags` | `false` | Strip every script tag from the output |
476
522
  | `asyncScriptTags` | `false` | Mark external scripts async |
477
- | `externalScripts` | `false` | Write injected state scripts as content-hashed same-origin files instead of inline; set by `csp: 'strict'` |
523
+ | `externalScripts` | `false` | Write injected state scripts as content-hashed same-origin files instead of inline; set by `csp: 'strict'` or `'hash'` |
478
524
  | `flatOutput` | `false` | Write `about.html` instead of `about/index.html` |
479
- | `notFoundRoute` | `/404` | Route emitted as `404.html`; a notice is logged when it is never prerendered |
480
- | `saveAs` | `html` | `html`, `png` or `jpeg` |
481
- | `metadata` | `null` | Defaults for the head component: `siteUrl`, `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
525
+ | `notFoundRoute` | `/404` | Route emitted as `404.html` with a robots noindex tag, even when the server answers HTTP 404; a notice is logged when it is never prerendered. A page can also declare `<meta name="prerender-status-code" content="404">` |
526
+ | `audit` | `true` | Check the written site for route health, broken links, metadata, sitemap and CSP issues |
527
+ | `failOnAuditError` | `false` | Fail when the audit finds an error |
528
+ | `auditIgnoreLinks` | `[]` | Link patterns excluded from the audit, strings or RegExp |
529
+ | `reportPath` | `null` | Write the versioned JSON run report to this file; URLs and the storage-state path are redacted |
530
+ | `saveAs` | `html` | `html`, `png` or `jpeg`; identical screenshots are left untouched |
531
+ | `metadata` | `null` | Defaults for the head component: `siteUrl` (origin only), `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
482
532
  | `verify` | `true` | Run the hydration verification pass |
533
+ | `framework` | `auto` | `auto`, `react`, `vue`, `svelte` or `preact`; detects the mounted framework from DOM markers |
534
+ | `retries` | `0` | Retry a retryable render failure this many times |
535
+ | `retryDelay` | `500` | Wait between render attempts, in ms |
536
+ | `traceOnError` | `false` | Write a Playwright trace for the final failed attempt under `snappy-traces/` |
483
537
  | `verifyChangedOnly` | `false` | Skip verification for routes whose HTML file did not change since the last run |
484
538
  | `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
485
539
  | `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
486
540
  | `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
541
+ | `failOnEmptyPage` | `true` | Fail when a route renders no app content |
542
+ | `failOnConsoleError` | `false` | Fail when the browser logs a console error while rendering |
543
+ | `failOnFailedRequest` | `false` | Fail when a same-origin request fails while rendering |
544
+ | `failOnDuplicateContent` | `false` | Fail when two routes produce byte-identical HTML |
545
+ | `failOnUndetected` | `false` | Fail when verification cannot classify any route as hydrated or re-rendered |
546
+ | `failOnVerificationError` | `false` | Fail when the verification pass cannot run on a route |
487
547
  | `failOnError` | `true` | Fail the run on route render errors |
488
548
  | `dryRun` | `false` | Render and report without writing |
489
549
  | `logLevel` | `info` | `silent`, `error`, `warn`, `info`, `debug` |
490
550
  | `serveCmd` | `null` | Command that starts the app server instead of static serving |
491
- | `url` | `null` | App URL, required with `serveCmd` |
551
+ | `url` | `null` | App origin, required with `serveCmd`; put a sub-path in `base` instead |
492
552
  | `serveCmdTimeout` | `30000` | How long to wait for `serveCmd` to answer |
493
553
  | `shutdownTimeout` | `5000` | Grace period before force-killing `serveCmd` |
494
554
 
@@ -501,46 +561,28 @@ import { prerender } from 'snappy-prerender/core';
501
561
 
502
562
  const report = await prerender({ sourceDir: 'dist', include: ['/'] });
503
563
  if (!report.ok) {
504
- for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
564
+ for (const failure of report.verification?.routes.filter((entry) => !entry.ok) ?? []) {
505
565
  console.error(failure.route, failure.hydrationErrors);
506
566
  }
567
+ for (const failure of report.errors) console.error(failure.route, failure.message);
507
568
  }
508
569
  ```
509
570
 
510
- The report exposes `routes`, `files` (with per-route `status`), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `duplicateTitles` (routes shipping more than one title), `verification` (per-route `mode` plus a `modes` summary), `preloadManifest`, `truncated`, and `ok`.
571
+ The report exposes `routes`, `files` (with per-route `status` and `noindex`), `routeDetails` (the per-route records used by the JSON report), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `consoleErrors`, `failedRequests`, `emptyPages`, `duplicatePages`, `duplicateTitles` (routes shipping more than one title), `verification` (nullable; per-route `mode`, `framework`, `firstMismatch`, `status` and `empty` plus `modes` and `frameworks` summaries), `audit` (nullable; `errors`, `warnings`, `linksChecked`), `preloadManifest`, `sitemap`, `pruned`, `truncated`, `notFoundRoutes`, `cspSkipped`, `traces`, and `ok`. See [docs/report.md](docs/report.md) for the JSON schema.
511
572
 
512
573
  `snappy-prerender` exports `DEFAULTS`, `prerender` and `resolveConfig`; the default export is the Vite plugin.
513
574
 
514
- ## Differences from react-snap
515
-
516
- Kept: crawling, include, concurrency, viewport, waitFor, executable path, external server, third-party skipping (now on by default), `userAgent`, `inlineCss`, `minifyHtml`, `minifyCss`, `cacheAjaxRequests`, `snapSaveState`, `preconnectThirdParty`, `preloadImages`, `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`, `destination`, `saveAs` (html/png/jpeg).
517
-
518
- Changed: `http2PushManifest` became `preloadManifest` (browsers removed HTTP/2 push), `fixInsertRule` became `captureRuntimeStyles` and also covers constructable stylesheets, `fixFormFields` became `captureFormState`, `puppeteerArgs` became `browserArgs`, `puppeteerIgnoreHTTPSErrors` became `ignoreHTTPSErrors`, `skipThirdPartyRequests` became `blockThirdParty` with the same default of allowing them, `exclude` and `maxRoutes` are new, and `notFoundRoute` replaces the `/404` include convention.
519
-
520
- Dropped: `sourceMaps` (declared but never referenced in react-snap's code), `fixWebpackChunksIssue` and `fixInsertRule` (webpack/Chrome-era workarounds), `port` (ephemeral loopback port instead), `puppeteer.cache`, and the preload polyfill (modulepreload is universally supported).
521
-
522
- ## Limitations
575
+ ## Further reading
523
576
 
524
- - **Build-time data is a snapshot.** Anything fetched during rendering is frozen into the HTML until the next build. Use `cacheAjaxRequests` and `snapSaveState` so the client replays the same data.
525
- - **Hydration mismatches are your app's to fix.** The verifier tells you exactly which route and which React error. Common causes: `Date.now()`, `Math.random()`, `localStorage` reads during render, viewport-dependent markup, and CSS-in-JS that re-injects styles on hydration.
526
- - **Markup that depends on load state cannot hydrate.** A component that swaps a placeholder for real content once an image or widget loads renders one thing during prerendering (where the network has settled) and another on the client's first render. The verifier reports those routes as `re-rendered`. Render deterministic markup instead — a plain `<img loading="lazy">` rather than a JS placeholder swap — or accept the re-render.
527
- - **Authenticated routes need `storageState`.** Never prerender personal data into static HTML without deciding to.
528
- - **Runtime-only routes** (heavy API dependence, real-time data) are better served by `serveCmd` or left client-rendered.
529
- - Route query strings are ignored; routes are deduplicated without them.
530
- - Routes whose decoded path contains dot segments or backslashes are skipped, so encoded traversal cannot create stray directories.
531
- - Crawling is unbounded unless `maxRoutes` is set; hitting the cap marks the run as failed so incomplete output is never shipped silently. Which routes are cut is concurrency-dependent on a large site — narrow `include`/`exclude` instead when you need a deterministic set.
532
- - **React 19 native metadata.** React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template title in place, so using both systems produces two titles in the output. Use one of them; the run warns when a page ships more than one `<title>`.
533
- - If two routes would write the same file — such as `/` and `/index` with `flatOutput` — neither is written and the run fails with a collision error.
534
- - Screenshots are viewport-width full-page captures, so they reflect the configured `viewport`, not a device matrix.
535
- - Output files from routes that no longer exist are left in place; clean the output directory or use a fresh `destination` when route sets shrink.
577
+ [Limitations](docs/limitations.md), [report schema and audit rules](docs/report.md), [migrating from react-snap](docs/react-snap-migration.md).
536
578
 
537
579
  ## Requirements
538
580
 
539
- - Node.js >= 20.19
581
+ - Node.js >= 22.12
540
582
  - Chrome or Edge installed (detected automatically), or allow the one-time Chrome for Testing download fallback
541
583
  - `beasties` only if you use `inlineCss: 'critical'` (optional peer dependency)
542
584
 
543
- Browser resolution order: installed Chrome, installed Edge, a Playwright-managed browser, then a Chrome for Testing download from Google's version-pinned HTTPS bucket into `SNAPPY_BROWSER_CACHE_DIR` (defaults to the platform cache directory). Chrome for Testing publishes no checksum feed, so that archive is trusted on the strength of HTTPS and the pinned URL unless you set `browserDownloadHash`, which makes the download fail if the SHA-256 does not match. Set `browserDownload: false` to forbid downloads entirely.
585
+ Browser resolution order: installed Chrome, installed Edge, a Playwright-managed browser, a Chrome for Testing build already in the tool cache, then a Chrome for Testing download from Google's version-pinned HTTPS bucket into `SNAPPY_BROWSER_CACHE_DIR` (defaults to the platform cache directory). An explicit `browser` choice never falls back to another engine. Set `browserDownloadVersion` to pin the build id and `browserDownloadHash` to pin the archive digest; without a hash the download is trusted on the strength of HTTPS and the pinned URL. Set `browserDownload: false` to forbid downloads entirely.
544
586
 
545
587
  ## Contributing
546
588
 
@@ -0,0 +1,58 @@
1
+ # Limitations
2
+
3
+ - **Build-time data is a snapshot.** Anything fetched during rendering is frozen into the
4
+ HTML until the next build. Use `cacheAjaxRequests` and `snapSaveState` so the client
5
+ replays the same data.
6
+ - **Hydration mismatches are your app's to fix.** The verifier tells you exactly which route
7
+ and which framework error. Common causes: `Date.now()`, `Math.random()`, `localStorage`
8
+ reads during render, viewport-dependent markup, and CSS-in-JS that re-injects styles on
9
+ hydration.
10
+ - **Markup that depends on load state cannot hydrate.** A component that swaps a placeholder
11
+ for real content once an image or widget loads renders one thing during prerendering
12
+ (where the network has settled) and another on the client's first render. The verifier
13
+ reports those routes as `re-rendered`. Render deterministic markup instead — a plain
14
+ `<img loading="lazy">` rather than a JS placeholder swap — or accept the re-render.
15
+ - **Authenticated routes need `storageState`.** Never prerender personal data into static
16
+ HTML without deciding to.
17
+ - **Runtime-only routes** (heavy API dependence, real-time data) are better served by
18
+ `serveCmd` or left client-rendered.
19
+ - Route query strings are ignored; routes are deduplicated without them.
20
+ - Routes whose decoded path contains dot segments or backslashes are skipped, so encoded
21
+ traversal cannot create stray directories.
22
+ - Crawling is unbounded unless `maxRoutes` is set; hitting the cap marks the run as failed
23
+ so incomplete output is never shipped silently. With `maxRoutes` or `routePriority`, the
24
+ crawl runs in deterministic waves and reports exactly which routes did not fit in
25
+ `truncated.droppedRoutes`.
26
+ - **React 19 native metadata.** React hoists `<title>` and `<meta>` rendered in JSX and
27
+ leaves an existing template title in place, so using both systems produces two titles in
28
+ the output. Use one of them; the run warns when a page ships more than one `<title>`.
29
+ - If two routes would write the same file — such as `/` and `/index` with `flatOutput` —
30
+ neither is written and the run fails with a collision error.
31
+ - Screenshots are viewport-width full-page captures, so they reflect the configured
32
+ `viewport`, not a device matrix. Identical screenshots are left untouched.
33
+ - **Output files from routes that no longer exist** are left in place unless
34
+ `pruneStaleRoutes` is enabled, which deletes only files listed in the previous run's
35
+ `.snappy-manifest.json`.
36
+ - **Dead extensionless links** are indistinguishable from SPA routes to the static server,
37
+ so a missing page can render the shell or the app's catch-all 404. Duplicate output is
38
+ reported; set `failOnDuplicateContent` to enforce it, and use an app-level 404 marker
39
+ where possible.
40
+ - **Metadata pointing at the build server** is removed by default
41
+ (`stripBuildOriginMetadata: true`) and reported. Set `metadata.siteUrl` so canonical and
42
+ social URLs survive.
43
+ - **`inlineCss`** rebases relative font and image URLs against each stylesheet. A sheet that
44
+ uses `@import` is left as a link because concatenating it would change the cascade.
45
+ - **`verifyChangedOnly` compares the generated HTML only.** If a bundle keeps the same
46
+ filename while its contents change, that route's HTML is unchanged and its verification
47
+ is skipped; content-hashed asset names (Vite and CRA defaults) make the HTML change as
48
+ well.
49
+ - **Destination directories are merged unless `cleanDestination` is on.** That option
50
+ empties the destination first; `pruneStaleRoutes` separately removes pages the run itself
51
+ generated.
52
+ - **`removeStyleTags`** cannot be combined with `inlineCss: 'inline'` or `true`; the config
53
+ is rejected instead of silently shipping pages without CSS.
54
+ - **CSP `hash` mode cannot cover inline event handlers or `style=""` attributes.** Move
55
+ those styles into a `<style>` element or a stylesheet; the audit warns when attributes are
56
+ present.
57
+ - **Failure traces can contain request data.** `traceOnError` is opt-in and writes a
58
+ Playwright archive under `snappy-traces/` in the output directory.