snappy-prerender 2.7.0 → 2.8.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 CHANGED
@@ -6,6 +6,55 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
  uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Earlier history predates
7
7
  the release tags; see the Git history for those changes.
8
8
 
9
+ ## [2.8.0] - 2026-10-31
10
+
11
+ ### Added
12
+
13
+ - `freezeTime`, `timezone` and `randomSeed` make `Date`/`Date.now()` and optional
14
+ `Math.random()` deterministic across render and verification. `SOURCE_DATE_EPOCH` is
15
+ honoured, and pages can read `window.__SNAPPY_NOW__` / `window.__SNAPPY_NOW_ISO__`.
16
+ - `hydration.allowRerender` and `hydration.skip`, plus `routeOverrides[].hydration`, let
17
+ explicitly dynamic routes re-render without being reported as failures.
18
+ - `locales` adds path or single locale modes with automatic `<html lang>`, canonical,
19
+ `og:locale`, `og:locale:alternate`, self/alternate/`x-default` hreflang, sitemap
20
+ alternates, translated route maps and hreflang audit rules.
21
+ - `seoLocale` seeds a cookie or `localStorage` locale before render and verification, and
22
+ optionally writes a pre-app bootstrap into the output. It supports localStorage, cookie
23
+ or both, `overwrite`, `navigator`, `forceHtmlLang`, route scoping, CSP hash and strict
24
+ externalization.
25
+ - CLI flags for the clock, hydration policies, simple locale matrices and SEO locale.
26
+ - Report fields for the fixed clock, locale counts, per-route locale, hydration policy,
27
+ allowed re-renders, ignored hydration errors and generated locale scripts.
28
+ - `Head` accepts `tags` for arbitrary `script`, `style`, `noscript` and `base` elements,
29
+ with attribute objects, text, trusted `noscript` HTML and explicit keys.
30
+ - `Head` accepts `htmlAttributes`, `bodyAttributes` and `titleAttributes`, restoring the
31
+ document's original attributes when no Head wants them.
32
+ - `Head` accepts `defaultTitle`, multiple `images` and multiple `alternateLocales`; repeated
33
+ same-name `extra` metas can opt into duplicates. These replace the client-side surface of
34
+ React Helmet and React Helmet Async.
35
+ - `Head` accepts `onChangeClientState` for observing added, removed and changed entries
36
+ after real DOM updates.
37
+ - React 19 native `<meta>` and `<link>` tags are adopted and updated instead of
38
+ duplicated. A correct native `<title>` is left to React so navigation can remove it, while
39
+ older template titles are removed.
40
+ - The head entry documents `HeadProps`, `HeadTag`, `HeadAttributes` and `HeadChange` with
41
+ JSDoc types; the package remains plain JavaScript and adds no TypeScript files.
42
+
43
+ ### Changed
44
+
45
+ - Final HTML processing uses one parsed document for preload injection, CSP hashes,
46
+ page metadata and preload resources instead of reparsing after each step.
47
+ - Render and verification use fewer Playwright round trips.
48
+ - Route writes, audit link checks, destination copies and sitemap-index fetches run with
49
+ bounded concurrency; route reports are merged in deterministic order.
50
+ - Preload hints are sorted, so repeated builds do not reorder modulepreload links.
51
+ - Inline stylesheet text and critical CSS processor state are cached within a run.
52
+ - Relative route-source loading and browser/server startup overlap where safe.
53
+ - The JSON report includes phase timings, and `npm run benchmark` measures large sites.
54
+ - `reuseVerificationContext` optionally reuses one browser context per verification worker.
55
+ - A not-found page's generated noindex replaces an existing robots directive instead of
56
+ adding a second meta tag.
57
+
9
58
  ## [2.7.0] - 2026-10-29
10
59
 
11
60
  ### Added
package/README.md CHANGED
@@ -1,7 +1,13 @@
1
+ <p align="center">
2
+ <img src="assets/snappy-logo.png" width="150" alt="snappy-prerender logo">
3
+ </p>
4
+
1
5
  # snappy-prerender
2
6
 
3
7
  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
8
 
9
+ Version 2.8 adds deterministic clock and randomness controls, locale and hreflang generation, cookie/localStorage SEO locale seeding, and a client-side head layer that replaces React Helmet or React Helmet Async for prerendered apps.
10
+
5
11
  [![npm](https://img.shields.io/npm/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
12
  [![npm downloads](https://img.shields.io/npm/dm/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
7
13
  [![node](https://img.shields.io/node/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
@@ -146,27 +152,77 @@ snappy({
146
152
  siteUrl: 'https://example.com',
147
153
  siteName: 'Example',
148
154
  titleTemplate: '%s | Example',
155
+ defaultTitle: 'Example',
149
156
  defaultImage: '/share.png',
150
157
  trailingSlash: 'never', // 'preserve' | 'always' | 'never'
151
158
  },
152
159
  });
153
160
  ```
154
161
 
162
+ ### Replacing React Helmet or React Helmet Async
163
+
164
+ For a client-rendered app, `Head` covers the tag and attribute surface React Helmet and React Helmet Async expose on the client. An app does not need either package:
165
+
166
+ ```jsx
167
+ <Head
168
+ title="Awards"
169
+ titleAttributes={{ itemProp: 'name' }}
170
+ htmlAttributes={{ className: 'theme-dark', 'data-theme': 'dark' }}
171
+ bodyAttributes={{ className: 'root', 'data-scroll': 'locked' }}
172
+ images={['/share/awards.png', '/share/awards-square.png']}
173
+ alternateLocales={['en_GB', 'fr_FR']}
174
+ tags={[
175
+ { tag: 'base', attrs: { href: '/' } },
176
+ { tag: 'script', attrs: { src: '/analytics.js', async: true } },
177
+ { tag: 'script', attrs: { type: 'application/json' }, text: '{"feature":true}' },
178
+ { tag: 'style', attrs: { media: 'screen' }, text: 'body { color: #111 }' },
179
+ { tag: 'noscript', html: '<img src="/pixel.png" alt="">' },
180
+ ]}
181
+ onChangeClientState={(state, added, removed) => {}}
182
+ />
183
+ ```
184
+
185
+ `Head` does not accept JSX children; use `tags`, `extra` or the props below for every value.
186
+
187
+ `tags` accepts `script`, `style`, `noscript` and `base`:
188
+
189
+ - `attrs` takes arbitrary attributes. `true` emits an empty attribute; `false`, `null` and `undefined` omit it. `className`, `htmlFor`, `httpEquiv` and the other React spellings map to their DOM names.
190
+ - `text` sets `textContent` for `script`, `style` and `noscript`. A closing-tag sequence inside script or style text is escaped, so the value cannot terminate its own element.
191
+ - `html` sets trusted `innerHTML` on `noscript` only. Never pass untrusted input.
192
+ - `key` gives a tag an explicit identity. Without one, external scripts key by `src` and inline tags key per mounted `Head`, so several scripts or styles survive together.
193
+ - `base` is a singleton; the last mounted `Head` that sets one wins.
194
+
195
+ `htmlAttributes`, `bodyAttributes` and `titleAttributes` write arbitrary attributes on the document element, body and generated title. Values the document already had are restored when no Head wants them, including after client-side navigation and after a prerendered page hydrates. `lang` and `dir` remain shortcuts; an entry in `htmlAttributes` wins when both are supplied.
196
+
197
+ `defaultTitle` fills the document title only when no mounted Head supplied a real `title`. It is not run through `titleTemplate`. `images` emits one `og:image` per URL and uses the first for `twitter:image`. `alternateLocales` emits one `og:locale:alternate` per value. Both keep every value through the merge.
198
+
199
+ `extra` stays the escape hatch for additional `meta` and `link` tags. Set `duplicates: true` on repeated same-name metas (for example `article:tag`) so each value keeps its own tag; an explicit `key` still works.
200
+
201
+ `onChangeClientState(state, added, removed)` runs after a real DOM change, not on every render. `state` groups the merged entries into `title`, `meta`, `link`, `script`, `style`, `noscript`, `base`, `htmlAttributes`, `bodyAttributes` and `titleAttributes`. `added` and `removed` are entry descriptors with `kind`, `key` and their value.
202
+
203
+ React 19 hoists native `<title>`, `<meta>` and `<link>` elements. `Head` adopts an existing tag with the same identity and updates it instead of adding a second one. Use one owner per tag: either React 19 native metadata or `Head`, not both for the same tag. The run still warns when a page ships more than one `<title>`.
204
+
205
+ The `snappy-prerender/head` entry is plain JavaScript with JSDoc types for `HeadProps`, `HeadTag`, `HeadAttributes`, `HeadChange` and the imperative API. It ships no TypeScript files.
206
+
207
+ This is a client-side head manager. It deliberately has no `HelmetProvider`, `helmetContext`, `HelmetData`, `renderStatic()`, streaming or server rendering API; the prerenderer renders the real app in a browser and captures the live head.
208
+
155
209
  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`.
156
210
 
157
211
  Rules:
158
212
  - `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.
213
+ - Tags are matched by the attribute a crawler reads, so existing tags are updated instead of duplicated. Tags that may legitimately repeat keep a value-specific identity, so multiple images, alternate locales, scripts and styles survive.
214
+ - `defaultTitle` is used only when no mounted Head supplied a title, and it is never run through `titleTemplate`.
160
215
  - 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.
216
+ - 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, together with the original html, body and title attributes.
162
217
  - The component renders nothing, so it does not affect hydration of page content.
163
218
  - 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
219
  - `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.
220
+ - `lang` and `dir` are written on `<html>`. Later Heads win, and the original values return when no Head wants them. `htmlAttributes` covers every other document attribute, and `bodyAttributes` / `titleAttributes` do the same for body and title.
221
+ - `alternateLocales` emits `og:locale:alternate` for each listed locale.
166
222
  - `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
223
  - `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
224
  - `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>`.
225
+ - On React 19, React also hoists `<title>`, `<meta>` and `<link>` from JSX. `Head` adopts a matching hoisted `<meta>` or `<link>` and updates it in place. A hoisted `<title>` that already has the Head text is left untouched so React can remove it on navigation; older template titles are removed so the written page has one document title. `titleAttributes` apply only when `Head` owns the title. Use one metadata system; the run warns when a page still ships more than one `<title>`.
170
226
  - Every metadata value has a CLI flag: `--metadata-site-url`, `--metadata-site-name`, `--metadata-title-template`, `--metadata-default-image`, `--metadata-trailing-slash`.
171
227
  - `react` is an optional peer dependency for this entry point. Non-React code can call `setHead(id, props)` and `clearHead(id)`.
172
228
 
@@ -184,7 +240,7 @@ Findings are returned in `report.audit` and logged. They do not fail the run unl
184
240
 
185
241
  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
242
 
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.
243
+ `routeOverrides` applies `timeout`, `waitFor`, `readySelector`, `readyFlag`, `crawl`, `storageState` and `hydration` 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
244
 
189
245
  `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
246
 
@@ -217,6 +273,103 @@ A verification navigation that returns HTTP 4xx/5xx, or a container with no cont
217
273
 
218
274
  **Rebuilds.** `verifyChangedOnly: true` skips verification for routes whose HTML file did not change. The report shows how many routes were skipped.
219
275
 
276
+ ## Deterministic time
277
+
278
+ Copyright lines and dated content often differ between prerendering and the verification reload. Fix the clock for both passes:
279
+
280
+ ```js
281
+ snappy({
282
+ freezeTime: true, // SOURCE_DATE_EPOCH, or the run start
283
+ timezone: 'UTC',
284
+ randomSeed: 42, // optional; deterministic Math.random for demo content
285
+ });
286
+ ```
287
+
288
+ `freezeTime` also accepts an ISO string or epoch milliseconds, which is the reproducible-build option:
289
+
290
+ ```js
291
+ freezeTime: '2026-05-04T12:00:00Z'
292
+ ```
293
+
294
+ Playwright's fixed clock is installed before navigation and keeps real timers running, so settling and scheduled work are unaffected. The page can read `window.__SNAPPY_NOW__` (epoch ms) or `window.__SNAPPY_NOW_ISO__`. `SOURCE_DATE_EPOCH` is honoured when `freezeTime` is `true`. A fixed clock is a build and verification control only: production still uses real time. For a live clock, render it client-only or use a hydration allow-list. `performance.now()` is not frozen because schedulers rely on it.
295
+
296
+ ## Hydration policies
297
+
298
+ If a route is intentionally live or random, allow its re-render explicitly instead of suppressing verification globally:
299
+
300
+ ```js
301
+ snappy({
302
+ hydration: {
303
+ allowRerender: ['/status', '/live/*'],
304
+ skip: ['/admin/*'],
305
+ },
306
+ routeOverrides: [
307
+ { match: '/live/private', hydration: 'strict' },
308
+ ],
309
+ });
310
+ ```
311
+
312
+ `allow-rerender` keeps the mode in the report but moves its hydration errors to `ignoredHydrationErrors` and does not fail the run. `skip` omits the route from verification and records it in `verification.skippedRoutes`. A matching `routeOverrides[].hydration` wins over the global lists; `skip` wins if both global lists match. The CLI flags are `--allow-rerender` and `--skip-hydration`.
313
+
314
+ ## Locales and hreflang
315
+
316
+ `locales` adds config-driven language metadata. Existing manual `Head` props still win; generated tags only fill missing values.
317
+
318
+ ```js
319
+ snappy({
320
+ locales: {
321
+ mode: 'path',
322
+ default: 'en',
323
+ xDefault: 'en',
324
+ hreflang: true,
325
+ list: [
326
+ { code: 'en', prefix: '', htmlLang: 'en', ogLocale: 'en_US' },
327
+ {
328
+ code: 'fr',
329
+ prefix: '/fr',
330
+ htmlLang: 'fr',
331
+ ogLocale: 'fr_FR',
332
+ routes: { '/about': '/a-propos' },
333
+ },
334
+ ],
335
+ },
336
+ });
337
+ ```
338
+
339
+ In `path` mode, the default locale is crawled once and every locale variant is rendered from the same base routes. `routes` maps translated slugs; a value is the path without the locale prefix. In `single` mode, `active` selects the locale rendered in this build and `list[].url` supplies the other deployments for hreflang. A locale entry can carry `locale` (Playwright/navigator), `timezone`, `storageState`, `cookies` and `localStorage`.
340
+
341
+ The package sets `<html lang>`/`dir` when missing, adds canonical URLs, `og:locale`, `og:locale:alternate`, self/alternate/`x-default` hreflang links, and feeds the same alternates into the sitemap. `Head` also accepts `alternateLocales={['en_US', 'de_DE']}` for app-managed Open Graph tags. The audit checks invalid and duplicate hreflang codes, missing self links, missing `x-default`, reciprocal links inside the same build, and an `<html lang>` that disagrees with the active locale.
342
+
343
+ Path mode requires one public origin and an app that routes the prefix or uses the translated `routes` map. Hreflang needs distinct crawlable URLs; a cookie-only locale does not create them. Use single mode for separate locale domains.
344
+
345
+ ## Cookie/localStorage SEO locale
346
+
347
+ For an app whose locale lives in a cookie or `localStorage`, `seoLocale` selects the SEO language before the app boots:
348
+
349
+ ```js
350
+ snappy({
351
+ seoLocale: {
352
+ code: 'fr',
353
+ source: 'both', // 'localStorage' | 'cookie' | 'both'
354
+ localStorageKey: 'locale',
355
+ cookie: { name: 'locale', path: '/', sameSite: 'Lax', secure: false },
356
+ seedRender: true,
357
+ seedVerification: true,
358
+ inject: true,
359
+ overwrite: true,
360
+ forceHtmlLang: true,
361
+ navigator: false,
362
+ routes: ['/*'],
363
+ },
364
+ });
365
+ ```
366
+
367
+ `seedRender` and `seedVerification` put the value into the prerender and verification browsers. `inject` writes a small bootstrap before the app bundle so real visitors get the same locale the static HTML was rendered in. `overwrite: false` only sets the value when missing, preserving a visitor's existing choice. `forceHtmlLang` overrides an app-owned `<html lang>`; `navigator` also emulates `navigator.language`/`Accept-Language`. In path mode the value comes from each locale's code; in single mode it comes from `locales.active` or `seoLocale.code`.
368
+
369
+ Under `csp: 'hash'` the bootstrap is hashed with the other inline scripts. Under `csp: 'strict'` or `externalScripts` it is written as a same-origin script. Combining `inject: true` with `removeScriptTags: true` is rejected because the bootstrap would be stripped.
370
+
371
+ This control does not create new crawlable URLs by itself. In single mode it produces one locale per output; for multiple crawlable locales use path-prefixed locales or separate builds/deployments. With `inject: false` the HTML is localized but a visitor with different storage may see another locale after hydration.
372
+
220
373
  ## Ready contract
221
374
 
222
375
  Most apps work with no configuration because the network-quiet heuristic is enough. Apps that fetch data after load can opt into a stronger signal:
@@ -473,7 +626,13 @@ The function is awaited once per run, and whatever it returns must be a plain op
473
626
  | `routePriority` | `[]` | Patterns ranked first when crawling, strings or RegExp; setting it selects deterministic BFS |
474
627
  | `routesFile` | `null` | Text or JSON route seeds, one route per line or `{ "routes": [] }`; a relative path resolves against the config file |
475
628
  | `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 |
629
+ | `routeOverrides` | `[]` | Per-route `{ match, timeout, waitFor, readySelector, readyFlag, crawl, storageState, hydration }`; later entries win |
630
+ | `freezeTime` | `false` | `true`, an ISO string or epoch ms; fixes `Date`/`Date.now()` in render and verification. `true` uses `SOURCE_DATE_EPOCH` or the run start |
631
+ | `timezone` | `null` | IANA timezone applied to render and verification contexts |
632
+ | `randomSeed` | `null` | Deterministic `Math.random()` during render and verification; never for security |
633
+ | `hydration` | `{ allowRerender: [], skip: [] }` | Route patterns whose re-render is allowed or whose verification is skipped |
634
+ | `locales` | `null` | Locale matrix for path or single mode, automatic hreflang, canonical, `og:locale` and sitemap alternates |
635
+ | `seoLocale` | `null` | Optional cookie/localStorage locale seed and output bootstrap for SEO builds |
477
636
  | `transformHtml` | `null` | Function `(html, { route, file, stats, config })` run before preload/CSP injection; may be async |
478
637
  | `concurrency` | derived | Parallel pages, derived from CPU count, capped at 8 |
479
638
  | `timeout` | `30000` | Navigation, ready and network-settle timeout in ms; each wait gets this budget |
@@ -535,6 +694,7 @@ The function is awaited once per run, and whatever it returns must be a plain op
535
694
  | `retryDelay` | `500` | Wait between render attempts, in ms |
536
695
  | `traceOnError` | `false` | Write a Playwright trace for the final failed attempt under `snappy-traces/` |
537
696
  | `verifyChangedOnly` | `false` | Skip verification for routes whose HTML file did not change since the last run |
697
+ | `reuseVerificationContext` | `false` | Reuse one browser context per verification worker; routes share storage, so use only for stateless apps |
538
698
  | `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
539
699
  | `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
540
700
  | `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
@@ -577,7 +737,6 @@ The report exposes `routes`, `files` (with per-route `status` and `noindex`), `r
577
737
  [Limitations](docs/limitations.md), [report schema and audit rules](docs/report.md), [migrating from react-snap](docs/react-snap-migration.md).
578
738
 
579
739
  ## Requirements
580
-
581
740
  - Node.js >= 22.12
582
741
  - Chrome or Edge installed (detected automatically), or allow the one-time Chrome for Testing download fallback
583
742
  - `beasties` only if you use `inlineCss: 'critical'` (optional peer dependency)
Binary file
@@ -6,7 +6,10 @@
6
6
  - **Hydration mismatches are your app's to fix.** The verifier tells you exactly which route
7
7
  and which framework error. Common causes: `Date.now()`, `Math.random()`, `localStorage`
8
8
  reads during render, viewport-dependent markup, and CSS-in-JS that re-injects styles on
9
- hydration.
9
+ hydration. Use `freezeTime` and `randomSeed` for deterministic build-time values, and
10
+ `hydration.allowRerender`/`skip` for genuinely live routes. A fixed clock is a build and
11
+ verification control only; production still uses real time. `performance.now()` is not
12
+ frozen because schedulers rely on it, and `crypto.randomUUID()` is not patched.
10
13
  - **Markup that depends on load state cannot hydrate.** A component that swaps a placeholder
11
14
  for real content once an image or widget loads renders one thing during prerendering
12
15
  (where the network has settled) and another on the client's first render. The verifier
@@ -14,6 +17,18 @@
14
17
  `<img loading="lazy">` rather than a JS placeholder swap — or accept the re-render.
15
18
  - **Authenticated routes need `storageState`.** Never prerender personal data into static
16
19
  HTML without deciding to.
20
+ - **Path locale prefixes need app routing.** `locales.mode: 'path'` renders `/fr/...` pages,
21
+ but the app still has to route that prefix or use translated `routes`. The prerenderer
22
+ cannot rewrite the client router. Path mode expects one public origin; use single mode
23
+ for separate locale domains.
24
+ - **Hreflang needs distinct URLs.** A locale selected only by cookie or `localStorage` is
25
+ not a crawlable alternate URL. Use path prefixes, subdomains or separate deployments.
26
+ - **`seoLocale` does not create new URLs.** In single mode it produces one locale per
27
+ output. It seeds the prerender/verification browser and optionally writes a bootstrap,
28
+ but it does not create multiple locale URLs. `inject: false` localizes the HTML without
29
+ forcing the visitor's stored locale.
30
+ - **`forceHtmlLang` overrides app metadata.** Use it only when the app hardcodes a language
31
+ that disagrees with the SEO locale.
17
32
  - **Runtime-only routes** (heavy API dependence, real-time data) are better served by
18
33
  `serveCmd` or left client-rendered.
19
34
  - Route query strings are ignored; routes are deduplicated without them.
@@ -23,9 +38,24 @@
23
38
  so incomplete output is never shipped silently. With `maxRoutes` or `routePriority`, the
24
39
  crawl runs in deterministic waves and reports exactly which routes did not fit in
25
40
  `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>`.
41
+ - **React 19 native metadata.** React hoists `<title>`, `<meta>` and `<link>` rendered in
42
+ JSX. `Head` adopts a matching hoisted `<meta>` or `<link>` and updates it. A hoisted
43
+ `<title>` that already carries the Head text is left to React (so React can remove it on
44
+ navigation) and older template titles are removed. `titleAttributes` apply only when
45
+ `Head` owns the title. An app should still use one owner per tag: either native React 19
46
+ metadata or `Head`. The run warns when a page ships more than one `<title>`.
47
+ - **`Head` is client-side only.** It manages the live document head while the app runs and
48
+ the prerenderer captures the result. It does not provide `HelmetProvider`,
49
+ `helmetContext`, `HelmetData`, `renderStatic()`, streaming head extraction or a
50
+ server-rendering API. A server-rendered app still needs React 19 native metadata or a
51
+ server head manager.
52
+ - **`tags` and `noscript.html` are trusted input.** Script and style text is escaped so it
53
+ cannot close its own element, but a `noscript` value is written as inner HTML exactly as
54
+ supplied. Never pass user-generated content there.
55
+ - **Attributes belong to the mounted `Head`.** html, body and title attributes are restored
56
+ when no Head wants them, including after client navigation and prerendered hydration. A
57
+ value written outside the head layer while a Head owns the same attribute is overwritten
58
+ on the next render.
29
59
  - If two routes would write the same file — such as `/` and `/index` with `flatOutput` —
30
60
  neither is written and the run fails with a collision error.
31
61
  - Screenshots are viewport-width full-page captures, so they reflect the configured
@@ -54,5 +84,11 @@
54
84
  - **CSP `hash` mode cannot cover inline event handlers or `style=""` attributes.** Move
55
85
  those styles into a `<style>` element or a stylesheet; the audit warns when attributes are
56
86
  present.
87
+ - **CSP `strict` does not rewrite `Head` `tags`.** An inline script supplied through
88
+ `tags` is the app's own policy decision; give it a `nonce` the host policy allows or
89
+ externalise it with `src`. The package only externalises its own captured state and SEO
90
+ locale bootstrap.
91
+ - **`reuseVerificationContext` shares browser storage between routes.** Use it only for
92
+ stateless apps; authenticated routes can see cookies or storage from another route.
57
93
  - **Failure traces can contain request data.** `traceOnError` is opt-in and writes a
58
94
  Playwright archive under `snappy-traces/` in the output directory.
package/docs/report.md CHANGED
@@ -9,18 +9,37 @@ is skipped in `--dry-run`.
9
9
  "version": 1,
10
10
  "generatedAt": "2026-10-28T00:00:00.000Z",
11
11
  "durationMs": 0,
12
+ "timings": { "render": 0, "write": 0, "artifacts": 0, "verify": 0, "audit": 0 },
12
13
  "ok": true,
14
+ "clock": { "fixedTime": 0, "timezone": "UTC" },
15
+ "locales": {
16
+ "mode": "path",
17
+ "default": "en",
18
+ "active": null,
19
+ "rendered": { "en": 0, "fr": 0 }
20
+ },
21
+ "seoLocale": {
22
+ "code": "fr",
23
+ "source": "both",
24
+ "inject": true,
25
+ "script": "snappy-seo-locale-abc123.js",
26
+ "scripts": ["snappy-seo-locale-abc123.js"]
27
+ },
13
28
  "summary": { "routes": 0, "files": 0, "errors": 0, "warnings": 0 },
14
29
  "verification": {
15
30
  "ok": true,
16
31
  "modes": { "hydrated": 0, "re-rendered": 0, "undetected": 0, "unknown": 0 },
17
32
  "frameworks": { "react": 0 },
18
33
  "skipped": 0,
34
+ "policySkipped": 0,
35
+ "allowed": 0,
36
+ "allowedRerender": 0,
19
37
  "errors": []
20
38
  },
21
39
  "routes": [
22
40
  {
23
41
  "route": "/",
42
+ "locale": "en",
24
43
  "order": 0,
25
44
  "file": "index.html",
26
45
  "status": "written",
@@ -39,12 +58,19 @@ is skipped in `--dry-run`.
39
58
  "description": "",
40
59
  "canonical": "",
41
60
  "robots": "",
61
+ "lang": "en",
62
+ "dir": null,
63
+ "ogLocale": "en_US",
42
64
  "h1Count": 1,
43
65
  "alternates": [],
66
+ "localeUrl": null,
44
67
  "images": [],
45
68
  "inlineStyleAttributes": 0
46
69
  },
47
70
  "hydrationErrors": [],
71
+ "ignoredHydrationErrors": [],
72
+ "hydrationPolicy": "strict",
73
+ "hydrationAllowed": false,
48
74
  "pageErrors": [],
49
75
  "consoleErrors": [],
50
76
  "failedRequests": [],
@@ -63,9 +89,10 @@ is skipped in `--dry-run`.
63
89
  }
64
90
  ```
65
91
 
66
- The document never includes configuration, `storageState`, captured AJAX state, cookies,
67
- tokens, request bodies or full asset URLs containing credentials. URLs in the JSON are
68
- reduced to their origin and path, and the configured `storageState` path is redacted
92
+ The document never includes the full configuration, `storageState`, captured AJAX state,
93
+ cookies, tokens, request bodies or full asset URLs containing credentials. It does include
94
+ non-secret summaries of the fixed clock, locale mode and SEO locale source. URLs in the JSON
95
+ are reduced to their origin and path, and the configured `storageState` path is redacted
69
96
  wherever it appears in a message. `order` is the crawl acceptance order; with
70
97
  `maxRoutes` or `routePriority` it follows the deterministic wave order rather than the
71
98
  alphabetical write order.
@@ -85,6 +112,9 @@ Errors:
85
112
  - `broken-internal-link` — an internal link with no written route or output file.
86
113
  - `duplicate-title` — two routes share a title.
87
114
  - `multiple-titles` — a page ships more than one document `<title>`.
115
+ - `hreflang-invalid` — an alternate has an invalid language code.
116
+ - `hreflang-duplicate` — the same hreflang appears more than once on a page.
117
+ - `hreflang-missing-return` — same-build alternates do not link back to each other.
88
118
  - `sitemap-missing-page` — an indexable page is absent from the sitemap.
89
119
  - `sitemap-unknown-page` — the sitemap lists a URL with no indexable page.
90
120
  - `sitemap-noindex-page` — the sitemap lists a noindex or 404 page.
@@ -94,6 +124,9 @@ Warnings:
94
124
  - `missing-title`, `missing-description`, `missing-canonical`.
95
125
  - `duplicate-description`.
96
126
  - `heading-count` — zero or more than one `<h1>`.
127
+ - `hreflang-missing-self`, `hreflang-missing-x-default`.
128
+ - `hreflang-canonical-mismatch`, `html-lang-missing`, `html-lang-mismatch`,
129
+ `og-locale-missing`.
97
130
  - `canonical-mismatch` — canonical differs from the expected public URL.
98
131
  - `build-origin-metadata` — a tag still points at the build server.
99
132
  - `orphan-page` — an indexable page no written page links to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snappy-prerender",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Drop-in prerendering for existing single-page apps. Renders your built app in a real browser, writes static HTML per route, reports React hydration mismatches, and optimises the output with critical CSS, link hints and cached AJAX state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "files": [
42
42
  "src",
43
+ "assets",
43
44
  "docs",
44
45
  "CHANGELOG.md",
45
46
  "LICENSE",
@@ -51,6 +52,7 @@
51
52
  "scripts": {
52
53
  "test": "node scripts/run-tests.mjs tests/unit",
53
54
  "test:integration": "node scripts/run-tests.mjs tests/integration",
55
+ "benchmark": "node scripts/benchmark.mjs",
54
56
  "lint": "biome check .",
55
57
  "lint:fix": "biome check --write .",
56
58
  "prepublishOnly": "npm run lint && npm test && npm run test:integration"