snappy-prerender 2.6.0-rc.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,140 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
6
+ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Earlier history predates
7
+ the release tags; see the Git history for those changes.
8
+
9
+ ## [2.6.0] - 2026-10-27
10
+
11
+ ### Added
12
+
13
+ - `cleanDestination` empties the destination before copying the build output, so assets
14
+ removed from a build do not linger.
15
+ - `pruneStaleRoutes` removes pages recorded by the previous run's `.snappy-manifest.json`
16
+ when they are no longer produced, without touching assets or user files.
17
+ - New build gates: `failOnEmptyPage` (on by default), `failOnConsoleError`,
18
+ `failOnFailedRequest`, `failOnDuplicateContent`, `failOnUndetected`,
19
+ `failOnVerificationError` and the optional `failOnBuildOriginMetadata`.
20
+ - `browserDownloadVersion` pins the Chrome for Testing build id; a cached build is now
21
+ used without contacting the version feed, so offline rebuilds work.
22
+ - `stripBuildOriginMetadata` removes canonical and social tags that point at the
23
+ prerender server instead of shipping loopback URLs.
24
+ - Reports now expose `consoleErrors`, `failedRequests`, `emptyPages`, `duplicatePages`
25
+ and `pruned`; page files carry `noindex`, and verification entries carry `status` and
26
+ `empty`.
27
+ - CLI flags for the new gates and for `quietPeriod`, `scrollStepDelay`,
28
+ `serveCmdTimeout`, `shutdownTimeout`, `maxCachedBytes`, `viewport`, `readyFlag`,
29
+ `readySelector`, `headless`, `cleanDestination` and `metadata.base`.
30
+ - A Node 22/24 test workflow and a dedicated integration suite for the audit fixes.
31
+
32
+ ### Changed
33
+
34
+ - **Breaking:** the minimum supported Node version is now 22.12, matching
35
+ `@puppeteer/browsers`.
36
+ - **Breaking:** an explicit `browser` choice no longer falls back to another engine.
37
+ `chromium` uses a Playwright-managed Chromium and asks you to install it when missing;
38
+ `auto` is the only choice that walks machine, Playwright cache, cached Chrome for
39
+ Testing, then download.
40
+ - **Breaking:** `include` is a true output allowlist. A wildcard-only list still seeds the
41
+ root for crawling, but the seed itself is not written. Passing `include: ['/']` is
42
+ treated as the default and leaves crawling enabled.
43
+ - **Breaking:** canonical, Open Graph and sitemap URLs share one page-path policy:
44
+ directory output keeps its trailing slash, flat output stays extensionless, and an
45
+ explicit `trailingSlash` wins.
46
+ - **Breaking:** sitemap entries no longer carry `lastmod` (a build date claimed every page
47
+ changed), noindex pages are excluded, and sitemaps above 50,000 URLs are split behind a
48
+ sitemap index.
49
+ - **Breaking:** `metadata.siteUrl` must be an origin without a path; sub-path deployments
50
+ belong in `base`. A `serveCmd` URL with a path is rejected for the same reason.
51
+ - **Breaking:** `removeStyleTags` combined with `inlineCss: 'inline'` or `true` is
52
+ rejected instead of silently removing the CSS that was just inlined.
53
+ - Empty output now fails a route by default; 404 pages are emitted with a robots
54
+ `noindex` tag.
55
+ - Third-party blocking now covers WebSockets and accepts hostnames with ports, wildcard
56
+ suffixes and leading-dot suffixes. Service workers are blocked during rendering and
57
+ verification.
58
+ - Verification uses a real browser user agent and the same reduced-motion context as the
59
+ render pass; mode counts are always logged and can fail the build with
60
+ `failOnUndetected`.
61
+ - Inlined stylesheets rebase relative `url()` values against their source stylesheet;
62
+ sheets using `@import` stay linked to preserve the cascade.
63
+ - Preload manifests are derived from the final HTML, so inline CSS, tag surgery and failed
64
+ writes no longer leave stale hints. The not-found route is omitted.
65
+ - Screenshots use the same unchanged-file check as HTML and dry runs report the image file
66
+ instead of an HTML one.
67
+ - `init` reads Vite's resolved `outDir` and `base`, and Create React App's `homepage`, so
68
+ the wizard no longer guesses `dist` and `/` for custom projects. During a prerelease it
69
+ prints the `@next` install tag.
70
+ - Unknown config keys and options that have no effect in the current combination now
71
+ produce warnings instead of being ignored silently.
72
+ - Rendered output is split across smaller modules; `run.js` is back under the 600-line
73
+ limit.
74
+
75
+ ### Fixed
76
+
77
+ - `setHead` called an undefined helper and threw `ReferenceError`.
78
+ - HTTP 4xx/5xx navigations, empty pages and zero-route runs could be written and reported
79
+ as successful; they now fail with route-level errors.
80
+ - The static server served `index.html` for dead extensionless links and failed dotted
81
+ routes; HTML navigations now fall back deliberately and duplicate output is detected.
82
+ - Canonical and `og:url` doubled a non-root `base` path.
83
+ - `verifyChangedOnly` could skip a changed page when a shared state script was unchanged.
84
+ - `asyncScriptTags` marked the injected state script `async`, allowing it to run after the
85
+ application bundle.
86
+ - `removeAttributes` with a global or sticky RegExp skipped every other match.
87
+ - Output collisions that differ only by case are detected on case-insensitive
88
+ filesystems.
89
+ - A destination inside the source directory recursed until the path length limit.
90
+ - Inlined CSS lost relative font and image URLs.
91
+ - Build-server metadata shipped loopback canonical and social URLs.
92
+ - Preload manifests described resources the final page no longer loaded.
93
+ - Sitemap and canonical URLs disagreed on trailing slashes and flat output.
94
+ - Allowed-host matching ignored ports and wildcards, and WebSocket requests bypassed
95
+ blocking.
96
+ - Browser downloads needed the network even when a cached browser was available.
97
+ - The CLI advertised `--preconnect-third-party` without defining it, and several other
98
+ documented flags had no CLI equivalent.
99
+
100
+ ## [2.6.0-rc.3] - 2026-10-03
101
+
102
+ ### Added
103
+
104
+ - Computed config files: `snappy.config.js` may export an async function that returns
105
+ options, so routes can come from a CMS, sitemap or file listing.
106
+ - Sitemap generation and the `sitemap` option.
107
+ - Stable output: unchanged pages, state scripts and artifacts are left untouched.
108
+ - React 16.8 and 17 fixtures and integration coverage.
109
+
110
+ ### Changed
111
+
112
+ - Documentation links to the npm package and notes the release-candidate channel.
113
+
114
+ ## [2.6.0-rc.2] - 2026-09-26
115
+
116
+ ### Added
117
+
118
+ - `snappy-prerender init` setup wizard, with flags for non-interactive use.
119
+ - CSP-friendly output: `csp: 'off' | 'strict'`, `externalScripts` and configurable
120
+ `criticalCssPreload`.
121
+
122
+ ## [2.6.0-rc.1] - 2026-09-26
123
+
124
+ ### Added
125
+
126
+ - The `snappy-prerender/head` entry point: `Head`, `setHead`, `clearHead`, metadata
127
+ defaults, canonical, Open Graph and Twitter tags, and client-side reconciliation.
128
+ - Head-layer audit fixes for tag ownership, restoration and repeated runs.
129
+
130
+ ## [2.5.0] - 2026-09-25
131
+
132
+ ### Added
133
+
134
+ - react-snap feature parity for v2: `inlineCss`, `minifyHtml`, `minifyCss`,
135
+ `preloadImages`, `cacheAjaxRequests`, `snapSaveState`, `preloadManifest`,
136
+ `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`,
137
+ `destination`, `saveAs`, `browserArgs` and `ignoreHTTPSErrors`.
138
+ - Hydration verification that reports `hydrated`, `re-rendered`, `undetected` and
139
+ `unknown`, plus per-route React mismatch messages.
140
+ - Third-party requests allowed by default, with opt-in blocking and host allowlists.
package/README.md CHANGED
@@ -7,7 +7,7 @@
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
12
  - **No SSR entry, no framework migration, no app changes.** If it builds to a `dist` directory, it can be prerendered.
13
13
  - **React 16.8, 17, 18 and 19, Vite 6/7/8, or any static build.**
@@ -74,9 +74,10 @@ 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
 
@@ -154,7 +155,7 @@ Rules worth knowing:
154
155
  - 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
156
  - The component renders nothing, so it cannot affect hydration of the page content.
156
157
  - 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
+ - Absolute URLs are required for `og:url`, `og:image` and canonical. `metadata.siteUrl` must be an origin (no path); put a sub-path deployment in `base`. Without `siteUrl`, URLs that fall back to the build server are removed from the output by default (`stripBuildOriginMetadata: true`) and the run warns. The `trailingSlash` convention applies to page URLs only; asset URLs are left alone. Canonical, social and sitemap URLs share one page-path policy: directory output keeps its slash, flat output stays extensionless, and an explicit `trailingSlash` wins.
158
159
  - `metadata.base` overrides the base path used for absolute URLs and defaults to the `base` option.
159
160
  - 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>`.
160
161
  - Every metadata value has a CLI flag: `--metadata-site-url`, `--metadata-site-name`, `--metadata-title-template`, `--metadata-default-image`, `--metadata-trailing-slash`.
@@ -175,6 +176,8 @@ After writing the output, every route is loaded again with the real client bundl
175
176
  | `undetected` | No React root was found on the container, so the mode could not be judged. |
176
177
  | `unknown` | The prerendered node was never seen, for example a container outside the usual ids. |
177
178
 
179
+ A route whose verification navigation returns HTTP 4xx/5xx, or whose container renders no content, is reported as failed with its `status` or `empty` flag. `undetected` and `unknown` are reported as mode counts; set `failOnUndetected: true` to make any such route fail the build. A verification pass that cannot run is listed in `verification.errors`; set `failOnVerificationError: true` to fail on those separately from hydration errors.
180
+
178
181
  `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.
179
182
 
180
183
  **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.
@@ -243,7 +246,7 @@ snappy({ preconnectThirdParty: true, preloadImages: true, preloadManifest: true
243
246
 
244
247
  - `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.
245
248
  - `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.
249
+ - `preloadManifest` writes `preload-manifest.json` into the output directory: for each route, a `Link` header value listing the scripts and stylesheets the final HTML still references, filtered by `ignoreForPreload` (default `['service-worker.js']`). Inline CSS and tag surgery are already reflected, the not-found route is omitted, and hints and route keys carry the base path, so serve these as Early Hints or `Link` response headers as they are.
247
250
 
248
251
  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.
249
252
 
@@ -253,7 +256,7 @@ react-snap called this `http2PushManifest`. Browsers removed HTTP/2 push (Chrome
253
256
  snappy({ sitemap: true, metadata: { siteUrl: 'https://example.com' } });
254
257
  ```
255
258
 
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.
259
+ `snappy` already knows every route it rendered, so it can write the sitemap for you. `sitemap.xml` is generated from the routes that were written — using the same page path policy as canonical URLs, with the base path applied, `noindex` pages and the not-found route left out — and split behind a sitemap index when it exceeds 50,000 URLs. Entries carry no `<lastmod>`, because a build date would claim every page changed on every build.
257
260
 
258
261
  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
262
 
@@ -429,14 +432,15 @@ The function is awaited once per run, and whatever it returns must be a plain op
429
432
  | --- | --- | --- |
430
433
  | `sourceDir` | `dist` | Built output directory |
431
434
  | `destination` | `null` | Copy the output here and write results here instead of in place |
435
+ | `cleanDestination` | `false` | Empty the destination before copying, so removed assets do not linger |
432
436
  | `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 |
437
+ | `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
438
  | `exclude` | `[]` | Routes to skip, strings or RegExp |
435
439
  | `crawl` | `true` | Follow same-origin links from seeds |
436
440
  | `maxDepth` | `null` | Crawl depth limit, `null` is unlimited, `0` renders seeds only |
437
441
  | `maxRoutes` | `null` | Stop after this many routes; hitting the cap marks the run incomplete |
438
442
  | `concurrency` | derived | Parallel pages, derived from CPU count, capped at 8 |
439
- | `timeout` | `30000` | Per-route timeout in ms |
443
+ | `timeout` | `30000` | Navigation, ready and network-settle timeout in ms; each wait gets this budget |
440
444
  | `quietPeriod` | `500` | Network must be idle this long before capture |
441
445
  | `readyFlag` | `__prerenderReady` | Window flag awaited when defined |
442
446
  | `readySelector` | `null` | Selector awaited when set |
@@ -447,6 +451,7 @@ The function is awaited once per run, and whatever it returns must be a plain op
447
451
  | `browser` | `auto` | `auto`, `chrome`, `msedge`, `chromium`, or a path to an executable |
448
452
  | `browserDownload` | `true` | Allow the Chrome for Testing fallback download |
449
453
  | `browserDownloadHash` | `null` | SHA-256 digest the fallback download must match, verified before use |
454
+ | `browserDownloadVersion` | `null` | Chrome for Testing build id to use for the cache and download instead of resolving `stable` |
450
455
  | `browserArgs` | `[]` | Extra browser launch arguments |
451
456
  | `headless` | `true` | Run the browser headless |
452
457
  | `ignoreHTTPSErrors` | `false` | Ignore TLS errors while rendering |
@@ -464,31 +469,40 @@ The function is awaited once per run, and whatever it returns must be a plain op
464
469
  | `minifyCss` | `false` | Minify CSS with clean-css, also applied to inlined CSS |
465
470
  | `preconnectThirdParty` | `true` | Add preconnect hints for every third-party origin the page contacts |
466
471
  | `preloadImages` | `false` | Add preload hints for same-origin images |
467
- | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints |
472
+ | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints, derived from the final HTML |
468
473
  | `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` |
474
+ | `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` |
470
475
  | `cacheAjaxRequests` | `false` | Expose captured JSON responses as `window.snapStore` |
471
- | `maxCachedBytes` | `5242880` | Largest JSON response cached per route, measured on the serialised body |
476
+ | `maxCachedBytes` | `5242880` | Largest JSON response cached per request, measured on the serialised body |
472
477
  | `removeBlobs` | `true` | Drop stylesheet links pointing at dead `blob:` URLs |
473
478
  | `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 |
479
+ | `stripBuildOriginMetadata` | `true` | Remove canonical and social tags whose URL points at the prerender server |
480
+ | `failOnBuildOriginMetadata` | `false` | Fail when metadata still points at the prerender server after stripping |
481
+ | `pruneStaleRoutes` | `false` | Delete pages listed by the previous run's manifest that this run no longer writes |
482
+ | `removeStyleTags` | `false` | Strip every style tag from the output; cannot be combined with `inlineCss` `'inline'` |
475
483
  | `removeScriptTags` | `false` | Strip every script tag from the output |
476
484
  | `asyncScriptTags` | `false` | Mark external scripts async |
477
485
  | `externalScripts` | `false` | Write injected state scripts as content-hashed same-origin files instead of inline; set by `csp: 'strict'` |
478
486
  | `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` |
487
+ | `notFoundRoute` | `/404` | Route emitted as `404.html` with a robots noindex tag; a notice is logged when it is never prerendered |
488
+ | `saveAs` | `html` | `html`, `png` or `jpeg`; identical screenshots are left untouched |
489
+ | `metadata` | `null` | Defaults for the head component: `siteUrl` (origin only), `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
482
490
  | `verify` | `true` | Run the hydration verification pass |
483
491
  | `verifyChangedOnly` | `false` | Skip verification for routes whose HTML file did not change since the last run |
484
492
  | `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
485
493
  | `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
486
494
  | `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
495
+ | `failOnEmptyPage` | `true` | Fail when a route renders no app content |
496
+ | `failOnConsoleError` | `false` | Fail when the browser logs a console error while rendering |
497
+ | `failOnFailedRequest` | `false` | Fail when a same-origin request fails while rendering |
498
+ | `failOnDuplicateContent` | `false` | Fail when two routes produce byte-identical HTML |
499
+ | `failOnUndetected` | `false` | Fail when verification cannot classify any route as hydrated or re-rendered |
500
+ | `failOnVerificationError` | `false` | Fail when the verification pass cannot run on a route |
487
501
  | `failOnError` | `true` | Fail the run on route render errors |
488
502
  | `dryRun` | `false` | Render and report without writing |
489
503
  | `logLevel` | `info` | `silent`, `error`, `warn`, `info`, `debug` |
490
504
  | `serveCmd` | `null` | Command that starts the app server instead of static serving |
491
- | `url` | `null` | App URL, required with `serveCmd` |
505
+ | `url` | `null` | App origin, required with `serveCmd`; put a sub-path in `base` instead |
492
506
  | `serveCmdTimeout` | `30000` | How long to wait for `serveCmd` to answer |
493
507
  | `shutdownTimeout` | `5000` | Grace period before force-killing `serveCmd` |
494
508
 
@@ -501,13 +515,14 @@ import { prerender } from 'snappy-prerender/core';
501
515
 
502
516
  const report = await prerender({ sourceDir: 'dist', include: ['/'] });
503
517
  if (!report.ok) {
504
- for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
518
+ for (const failure of report.verification?.routes.filter((entry) => !entry.ok) ?? []) {
505
519
  console.error(failure.route, failure.hydrationErrors);
506
520
  }
521
+ for (const failure of report.errors) console.error(failure.route, failure.message);
507
522
  }
508
523
  ```
509
524
 
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`.
525
+ The report exposes `routes`, `files` (with per-route `status` and `noindex`), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `consoleErrors`, `failedRequests`, `emptyPages`, `duplicatePages`, `duplicateTitles` (routes shipping more than one title), `verification` (nullable; per-route `mode`, `status` and `empty` plus a `modes` summary), `preloadManifest`, `sitemap`, `pruned`, `truncated`, and `ok`.
511
526
 
512
527
  `snappy-prerender` exports `DEFAULTS`, `prerender` and `resolveConfig`; the default export is the Vite plugin.
513
528
 
@@ -531,16 +546,22 @@ Dropped: `sourceMaps` (declared but never referenced in react-snap's code), `fix
531
546
  - 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
547
  - **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
548
  - 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.
549
+ - Screenshots are viewport-width full-page captures, so they reflect the configured `viewport`, not a device matrix. Identical screenshots are left untouched.
550
+ - **Output files from routes that no longer exist** are left in place unless `pruneStaleRoutes` is enabled, which deletes only files listed in the previous run's `.snappy-manifest.json`.
551
+ - **Dead extensionless links** are indistinguishable from SPA routes to the static server, so a missing page can render the shell or the app's catch-all 404. Duplicate output is reported; set `failOnDuplicateContent` to enforce it, and use an app-level 404 marker where possible.
552
+ - **Metadata pointing at the build server** is removed by default (`stripBuildOriginMetadata: true`) and reported. Set `metadata.siteUrl` so canonical and social URLs survive.
553
+ - **`inlineCss`** rebases relative font and image URLs against each stylesheet. A sheet that uses `@import` is left as a link because concatenating it would change the cascade.
554
+ - **`verifyChangedOnly` compares the generated HTML only.** If a bundle keeps the same filename while its contents change, that route's HTML is unchanged and its verification is skipped; content-hashed asset names (Vite and CRA defaults) make the HTML change as well.
555
+ - **Destination directories are merged unless `cleanDestination` is on.** That option empties the destination first; `pruneStaleRoutes` separately removes pages the run itself generated.
556
+ - **`removeStyleTags`** cannot be combined with `inlineCss: 'inline'` or `true`; the config is rejected instead of silently shipping pages without CSS.
536
557
 
537
558
  ## Requirements
538
559
 
539
- - Node.js >= 20.19
560
+ - Node.js >= 22.12
540
561
  - Chrome or Edge installed (detected automatically), or allow the one-time Chrome for Testing download fallback
541
562
  - `beasties` only if you use `inlineCss: 'critical'` (optional peer dependency)
542
563
 
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.
564
+ 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
565
 
545
566
  ## Contributing
546
567
 
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.6.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,17 @@
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
+ "CHANGELOG.md",
43
44
  "LICENSE",
44
45
  "README.md"
45
46
  ],
46
- "sideEffects": false,
47
+ "sideEffects": [
48
+ "./src/head/manager.js"
49
+ ],
47
50
  "scripts": {
48
51
  "test": "node scripts/run-tests.mjs tests/unit",
49
52
  "test:integration": "node scripts/run-tests.mjs tests/integration",
@@ -65,7 +68,7 @@
65
68
  "beasties": "0.4.1",
66
69
  "react": "19.2.0",
67
70
  "react-dom": "19.2.0",
68
- "vite": "7.3.6"
71
+ "vite": "7.3.7"
69
72
  },
70
73
  "workspaces": [
71
74
  "tests/fixtures/react16-app",
package/src/cli.js CHANGED
@@ -30,17 +30,33 @@ Options:
30
30
  --max-depth <n> Crawl depth limit, 0 renders roots only (default: unlimited)
31
31
  --max-routes <n> Stop after this many routes and fail the run (default: unlimited)
32
32
  --concurrency <n> Parallel browser pages (default: derived from CPU count)
33
- --timeout <ms> Per-route timeout (default: 30000)
33
+ --timeout <ms> Per-route navigation/settle timeout (default: 30000)
34
+ --quiet-period <ms> Network must be idle this long before capture (default: 500)
35
+ --scroll-step-delay <ms> Delay between scroll steps (default: 100)
36
+ --viewport <WxH> Browser viewport (default: 1280x720)
37
+ --ready-selector <sel> Selector awaited when set
38
+ --ready-flag <name> Window flag awaited when defined (default: __prerenderReady)
39
+ --no-headless Run the browser visibly
34
40
  --wait-for <selector> Wait for a selector on every route before capture
35
41
  --browser <choice> auto | chrome | msedge | chromium | path (default: auto)
36
42
  --no-browser-download Refuse the Chrome for Testing fallback download
37
43
  --browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
44
+ --browser-download-version <build> Pin the Chrome for Testing build id
38
45
  --storage-state <file> Playwright storage state for authenticated routes
39
46
  --no-verify Skip the hydration verification pass
40
47
  --verify-changed-only Skip verification for routes whose HTML did not change
41
48
  --fail-on-hydration-error Fail the build when hydration errors are found
42
49
  --fail-on-rerender Fail when a route re-renders instead of hydrating
43
50
  --fail-on-page-error Fail when a page throws while it is being rendered
51
+ --no-fail-on-error Report route errors without failing the run
52
+ --fail-on-empty-page Fail when a route renders no app content (default: on)
53
+ --no-fail-on-empty-page Allow an empty page and report it instead
54
+ --fail-on-console-error Fail when the browser logs a console error while rendering
55
+ --fail-on-failed-request Fail when a same-origin request fails while rendering
56
+ --fail-on-duplicate-content Fail when two routes produce identical HTML
57
+ --fail-on-build-origin-metadata Fail when metadata still points at the prerender server
58
+ --fail-on-undetected Fail when verification cannot find a framework root
59
+ --fail-on-verification-error Fail when the verification pass cannot run
44
60
  --block-third-party Block third-party requests while rendering
45
61
  --no-block-third-party Allow third-party requests (the default)
46
62
  --allowed-hosts <hosts> Third-party hosts to allow while blocking the rest, comma separated
@@ -51,6 +67,7 @@ Options:
51
67
  --flat Write about.html instead of about/index.html
52
68
  --not-found <route> Route emitted as 404.html (default: /404)
53
69
  --destination <dir> Write output to another directory (default: in place)
70
+ --clean-destination Empty the destination before copying the build output
54
71
  --save-as <format> html | png | jpeg (default: html)
55
72
  --user-agent <value> User agent used while rendering (default: SnappyPrerender)
56
73
  --metadata-site-url <url> Public site URL for canonical and og:url
@@ -58,6 +75,7 @@ Options:
58
75
  --metadata-title-template <t> Title template, for example '%s | Example'
59
76
  --metadata-default-image <url> Share image for routes without one
60
77
  --metadata-trailing-slash <mode> preserve | always | never
78
+ --metadata-base <path> Base path used for absolute metadata URLs
61
79
  --inline-css <strategy> inline | critical (critical needs the beasties package)
62
80
  --critical-css-preload <mode> Swap for deferred CSS: none | body | media | swap | swap-high | swap-low | js | js-lazy
63
81
  --minify-html Minify the generated HTML
@@ -69,6 +87,7 @@ Options:
69
87
  --sitemap Write sitemap.xml from the rendered routes (needs metadata.siteUrl)
70
88
  --ignore-for-preload <names> File names excluded from the manifest, comma separated
71
89
  --cache-ajax-requests Expose captured JSON responses as window.snapStore
90
+ --max-cached-bytes <n> Largest JSON response cached per request (default: 5242880)
72
91
  --remove-scripts Strip every script tag from the output
73
92
  --remove-styles Strip every style tag from the output
74
93
  --async-scripts Mark external scripts async
@@ -76,10 +95,14 @@ Options:
76
95
  --csp <mode> off | strict: strict makes the output work under script-src 'self'
77
96
  --no-remove-blobs Keep blob stylesheet links instead of dropping them
78
97
  --remove-attributes <names> Drop these attributes from the output, comma separated
98
+ --no-strip-build-origin-metadata Keep tags that point at the prerender server
99
+ --prune-stale-routes Delete pages listed by the previous run's manifest
79
100
  --ignore-https-errors Ignore TLS errors while rendering
80
101
  --browser-args <args> Extra browser launch arguments, comma separated
81
102
  --url <url> App URL to connect to when using --serve-cmd
82
103
  --serve-cmd <command> Command that starts the app server instead of static serving
104
+ --serve-cmd-timeout <ms> How long to wait for the command server (default: 30000)
105
+ --shutdown-timeout <ms> Grace period before force-killing the server (default: 5000)
83
106
  --dry-run Render and report without writing files
84
107
  --log-level <level> silent | error | warn | info | debug (default: info)
85
108
  --yes, -y Accept every default, for a shell without a terminal
@@ -105,16 +128,34 @@ async function main() {
105
128
  'max-routes': { type: 'string' },
106
129
  concurrency: { type: 'string' },
107
130
  timeout: { type: 'string' },
131
+ 'quiet-period': { type: 'string' },
132
+ 'scroll-step-delay': { type: 'string' },
133
+ 'max-cached-bytes': { type: 'string' },
134
+ 'serve-cmd-timeout': { type: 'string' },
135
+ 'shutdown-timeout': { type: 'string' },
136
+ viewport: { type: 'string' },
137
+ 'ready-flag': { type: 'string' },
138
+ 'ready-selector': { type: 'string' },
139
+ headless: { type: 'boolean' },
108
140
  'wait-for': { type: 'string' },
109
141
  browser: { type: 'string' },
110
142
  'no-browser-download': { type: 'boolean' },
111
143
  'browser-download-hash': { type: 'string' },
144
+ 'browser-download-version': { type: 'string' },
112
145
  'storage-state': { type: 'string' },
113
146
  'no-verify': { type: 'boolean' },
114
147
  'verify-changed-only': { type: 'boolean' },
115
148
  'fail-on-hydration-error': { type: 'boolean' },
149
+ 'fail-on-error': { type: 'boolean' },
116
150
  'fail-on-rerender': { type: 'boolean' },
117
151
  'fail-on-page-error': { type: 'boolean' },
152
+ 'fail-on-empty-page': { type: 'boolean' },
153
+ 'fail-on-console-error': { type: 'boolean' },
154
+ 'fail-on-failed-request': { type: 'boolean' },
155
+ 'fail-on-duplicate-content': { type: 'boolean' },
156
+ 'fail-on-build-origin-metadata': { type: 'boolean' },
157
+ 'fail-on-undetected': { type: 'boolean' },
158
+ 'fail-on-verification-error': { type: 'boolean' },
118
159
  'no-block-third-party': { type: 'boolean' },
119
160
  'block-third-party': { type: 'boolean' },
120
161
  'allowed-hosts': { type: 'string', multiple: true },
@@ -125,6 +166,7 @@ async function main() {
125
166
  flat: { type: 'boolean' },
126
167
  'not-found': { type: 'string' },
127
168
  destination: { type: 'string' },
169
+ 'clean-destination': { type: 'boolean' },
128
170
  'save-as': { type: 'string' },
129
171
  'user-agent': { type: 'string' },
130
172
  'metadata-site-url': { type: 'string' },
@@ -132,12 +174,14 @@ async function main() {
132
174
  'metadata-title-template': { type: 'string' },
133
175
  'metadata-default-image': { type: 'string' },
134
176
  'metadata-trailing-slash': { type: 'string' },
177
+ 'metadata-base': { type: 'string' },
135
178
  'inline-css': { type: 'string' },
136
179
  'critical-css-preload': { type: 'string' },
137
180
  csp: { type: 'string' },
138
181
  'minify-html': { type: 'boolean' },
139
182
  'minify-css': { type: 'boolean' },
140
183
  'no-preconnect-third-party': { type: 'boolean' },
184
+ 'preconnect-third-party': { type: 'boolean' },
141
185
  'preload-images': { type: 'boolean' },
142
186
  'preload-manifest': { type: 'boolean' },
143
187
  sitemap: { type: 'boolean' },
@@ -149,6 +193,8 @@ async function main() {
149
193
  'external-scripts': { type: 'boolean' },
150
194
  'no-remove-blobs': { type: 'boolean' },
151
195
  'remove-attributes': { type: 'string', multiple: true },
196
+ 'no-strip-build-origin-metadata': { type: 'boolean' },
197
+ 'prune-stale-routes': { type: 'boolean' },
152
198
  'ignore-https-errors': { type: 'boolean' },
153
199
  'browser-args': { type: 'string', multiple: true },
154
200
  url: { type: 'string' },
@@ -205,18 +251,52 @@ function cliOptions({ values, positionals }) {
205
251
  if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
206
252
  if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
207
253
  if (values.timeout !== undefined) options.timeout = Number(values.timeout);
254
+ if (values['quiet-period'] !== undefined) options.quietPeriod = Number(values['quiet-period']);
255
+ if (values['scroll-step-delay'] !== undefined) {
256
+ options.scrollStepDelay = Number(values['scroll-step-delay']);
257
+ }
258
+ if (values['max-cached-bytes'] !== undefined) {
259
+ options.maxCachedBytes = Number(values['max-cached-bytes']);
260
+ }
261
+ if (values['serve-cmd-timeout'] !== undefined) {
262
+ options.serveCmdTimeout = Number(values['serve-cmd-timeout']);
263
+ }
264
+ if (values['shutdown-timeout'] !== undefined) {
265
+ options.shutdownTimeout = Number(values['shutdown-timeout']);
266
+ }
267
+ if (values.viewport !== undefined) {
268
+ const match = /^(\d+)x(\d+)$/i.exec(values.viewport);
269
+ if (!match) throw new Error('viewport must look like 1280x720');
270
+ options.viewport = { width: Number(match[1]), height: Number(match[2]) };
271
+ }
272
+ if (values['ready-selector'] !== undefined) options.readySelector = values['ready-selector'];
273
+ if (values['ready-flag'] !== undefined) options.readyFlag = values['ready-flag'];
274
+ if (values.headless !== undefined) options.headless = values.headless;
208
275
  if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
209
276
  if (values.browser !== undefined) options.browser = values.browser;
210
277
  if (values['no-browser-download']) options.browserDownload = false;
211
278
  if (values['browser-download-hash'] !== undefined) {
212
279
  options.browserDownloadHash = values['browser-download-hash'];
213
280
  }
281
+ if (values['browser-download-version'] !== undefined) {
282
+ options.browserDownloadVersion = values['browser-download-version'];
283
+ }
214
284
  if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
215
285
  if (values['no-verify']) options.verify = false;
216
286
  if (values['verify-changed-only']) options.verifyChangedOnly = true;
217
287
  if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
218
288
  if (values['fail-on-rerender']) options.failOnRerender = true;
219
289
  if (values['fail-on-page-error']) options.failOnPageError = true;
290
+ if (values['fail-on-error'] !== undefined) options.failOnError = values['fail-on-error'];
291
+ if (values['fail-on-empty-page'] !== undefined) {
292
+ options.failOnEmptyPage = values['fail-on-empty-page'];
293
+ }
294
+ if (values['fail-on-console-error']) options.failOnConsoleError = true;
295
+ if (values['fail-on-failed-request']) options.failOnFailedRequest = true;
296
+ if (values['fail-on-duplicate-content']) options.failOnDuplicateContent = true;
297
+ if (values['fail-on-build-origin-metadata']) options.failOnBuildOriginMetadata = true;
298
+ if (values['fail-on-undetected']) options.failOnUndetected = true;
299
+ if (values['fail-on-verification-error']) options.failOnVerificationError = true;
220
300
  if (values['no-block-third-party']) options.blockThirdParty = false;
221
301
  if (values['block-third-party']) options.blockThirdParty = true;
222
302
  if (values['allowed-hosts'] !== undefined)
@@ -228,6 +308,7 @@ function cliOptions({ values, positionals }) {
228
308
  if (values.flat) options.flatOutput = true;
229
309
  if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
230
310
  if (values.destination !== undefined) options.destination = values.destination;
311
+ if (values['clean-destination']) options.cleanDestination = true;
231
312
  if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
232
313
  if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
233
314
  const metadata = metadataOptions(values);
@@ -241,6 +322,7 @@ function cliOptions({ values, positionals }) {
241
322
  if (values['minify-html']) options.minifyHtml = true;
242
323
  if (values['minify-css']) options.minifyCss = true;
243
324
  if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
325
+ if (values['preconnect-third-party']) options.preconnectThirdParty = true;
244
326
  if (values['preload-images']) options.preloadImages = true;
245
327
  if (values['preload-manifest']) options.preloadManifest = true;
246
328
  if (values.sitemap) options.sitemap = true;
@@ -256,6 +338,8 @@ function cliOptions({ values, positionals }) {
256
338
  if (values['remove-attributes'] !== undefined) {
257
339
  options.removeAttributes = splitList(values['remove-attributes']);
258
340
  }
341
+ if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
342
+ if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
259
343
  if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
260
344
  if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
261
345
  if (values.url !== undefined) options.url = values.url;
@@ -298,14 +382,14 @@ async function runInit({ values }) {
298
382
  force: values.force === true,
299
383
  });
300
384
  log.success(`Wrote ${CONFIG_FILE}`);
301
- printNextSteps({ detected, log });
385
+ printNextSteps({ detected, log, tag: readVersion().includes('-') ? '@next' : '' });
302
386
  }
303
387
 
304
388
  // Turns the flags the CLI already understands into the wizard's starting answers.
305
389
  function defaultsFromFlags(detected, flags) {
306
390
  return {
307
391
  sourceDir: flags.sourceDir ?? detected.sourceDir,
308
- base: flags.base ?? '/',
392
+ base: flags.base ?? detected.base ?? '/',
309
393
  include: flags.include ?? ['/'],
310
394
  crawl: flags.crawl ?? true,
311
395
  siteUrl: flags.metadata?.siteUrl ?? '',
@@ -388,9 +472,9 @@ function validatedAnswers(answers, log) {
388
472
  }
389
473
 
390
474
  // Prints what to do next: install if needed, run it, and where the head layer goes.
391
- function printNextSteps({ detected, log }) {
475
+ function printNextSteps({ detected, log, tag = '' }) {
392
476
  const run = runnerCommand(detected.runner, PACKAGE_NAME);
393
- const install = installCommand(detected.runner);
477
+ const install = installCommand(detected.runner, tag);
394
478
  log.info('');
395
479
  log.info('Next steps:');
396
480
  if (!detected.installed) log.info(` Install ${install}`);
@@ -419,10 +503,11 @@ function runnerCommand(runner, binary) {
419
503
  }
420
504
 
421
505
  // Builds the command that installs this package as a dev dependency.
422
- function installCommand(runner) {
423
- if (runner === 'pnpm') return `pnpm add -D ${PACKAGE_NAME}`;
424
- if (runner === 'yarn') return `yarn add -D ${PACKAGE_NAME}`;
425
- return `npm install --save-dev ${PACKAGE_NAME}`;
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}`;
426
511
  }
427
512
 
428
513
  // Collects the metadata flags, or null when none was passed so a config file value survives.
@@ -441,6 +526,7 @@ function metadataOptions(values) {
441
526
  if (values['metadata-trailing-slash'] !== undefined) {
442
527
  metadata.trailingSlash = values['metadata-trailing-slash'];
443
528
  }
529
+ if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
444
530
  return Object.keys(metadata).length > 0 ? metadata : null;
445
531
  }
446
532