snappy-prerender 2.6.0-rc.2 → 2.6.0-rc.3

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/README.md CHANGED
@@ -3,11 +3,14 @@
3
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.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
+ [![npm downloads](https://img.shields.io/npm/dm/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
7
  [![node](https://img.shields.io/node/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
7
8
  [![license](https://img.shields.io/npm/l/snappy-prerender.svg)](https://github.com/sayakmukherjee080/snappy-prerender/blob/main/LICENSE)
8
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.
11
+
9
12
  - **No SSR entry, no framework migration, no app changes.** If it builds to a `dist` directory, it can be prerendered.
10
- - **React 18 and React 19, Vite 6/7/8, or any static build.**
13
+ - **React 16.8, 17, 18 and 19, Vite 6/7/8, or any static build.**
11
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
15
 
13
16
  ## Who this is for
@@ -60,6 +63,7 @@ npx snappy-prerender dist --include /,/pricing,/blog/* --exclude /blog/drafts/*
60
63
  npx snappy-prerender dist --block-third-party --allowed-hosts cms.example.com
61
64
  npx snappy-prerender dist --metadata-site-url https://example.com --metadata-title-template '%s | Example'
62
65
  npx snappy-prerender dist --inline-css critical --minify-html --preload-manifest
66
+ npx snappy-prerender dist --sitemap --verify-changed-only
63
67
  npx snappy-prerender dist --csp strict --inline-css critical
64
68
  ```
65
69
 
@@ -175,6 +179,10 @@ After writing the output, every route is loaded again with the real client bundl
175
179
 
176
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.
177
181
 
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.
183
+
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.
185
+
178
186
  ## Ready contract
179
187
 
180
188
  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:
@@ -239,6 +247,16 @@ snappy({ preconnectThirdParty: true, preloadImages: true, preloadManifest: true
239
247
 
240
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.
241
249
 
250
+ ### Sitemap
251
+
252
+ ```js
253
+ snappy({ sitemap: true, metadata: { siteUrl: 'https://example.com' } });
254
+ ```
255
+
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.
257
+
258
+ 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
+
242
260
  ### Tag surgery
243
261
 
244
262
  ```js
@@ -252,6 +270,24 @@ snappy({
252
270
 
253
271
  The persisted metadata element is exempt from `removeScriptTags`; everything else is removed as asked.
254
272
 
273
+ ### Removing volatile markup
274
+
275
+ Mark any element with `data-prerender-remove` and it is dropped from the output:
276
+
277
+ ```jsx
278
+ <div data-prerender-remove>{ads.render()}</div>
279
+ ```
280
+
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.
282
+
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:
284
+
285
+ ```js
286
+ snappy({ removeAttributes: ['data-menu-id', 'aria-controls'] });
287
+ ```
288
+
289
+ As with removed markup, only strip attributes React does not render as props; if you guess wrong, the hydration verifier tells you which route.
290
+
255
291
  ### Strict CSP
256
292
 
257
293
  One dial decides how the injected pieces are delivered. `csp: 'off'` (default) leaves them inline, which is cheapest to load. `csp: 'strict'` makes the whole output work under `script-src 'self'` with no `unsafe-inline`:
@@ -370,6 +406,23 @@ export default {
370
406
  };
371
407
  ```
372
408
 
409
+ The config can also be an async function that returns the options. That is how routes get discovered from a CMS, a sitemap or a directory listing, without a separate prebuild script:
410
+
411
+ ```js
412
+ // snappy.config.js
413
+ export default async function () {
414
+ const response = await fetch('https://cms.example.com/api/posts?fields=slug');
415
+ const posts = await response.json();
416
+ return {
417
+ sourceDir: 'dist',
418
+ include: ['/', '/blog', ...posts.map((post) => `/blog/${post.slug}`)],
419
+ metadata: { siteUrl: 'https://example.com', siteName: 'Example' },
420
+ };
421
+ }
422
+ ```
423
+
424
+ The function is awaited once per run, and whatever it returns must be a plain options object. If it throws, the CLI reports the reason and stops before rendering anything.
425
+
373
426
  ## Options
374
427
 
375
428
  | Option | Default | Description |
@@ -413,9 +466,11 @@ export default {
413
466
  | `preloadImages` | `false` | Add preload hints for same-origin images |
414
467
  | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints |
415
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` |
416
470
  | `cacheAjaxRequests` | `false` | Expose captured JSON responses as `window.snapStore` |
417
471
  | `maxCachedBytes` | `5242880` | Largest JSON response cached per route, measured on the serialised body |
418
472
  | `removeBlobs` | `true` | Drop stylesheet links pointing at dead `blob:` URLs |
473
+ | `removeAttributes` | `[]` | Attribute names or RegExps to drop from the output, for libraries that stamp per-load ids |
419
474
  | `removeStyleTags` | `false` | Strip every style tag from the output |
420
475
  | `removeScriptTags` | `false` | Strip every script tag from the output |
421
476
  | `asyncScriptTags` | `false` | Mark external scripts async |
@@ -425,6 +480,7 @@ export default {
425
480
  | `saveAs` | `html` | `html`, `png` or `jpeg` |
426
481
  | `metadata` | `null` | Defaults for the head component: `siteUrl`, `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
427
482
  | `verify` | `true` | Run the hydration verification pass |
483
+ | `verifyChangedOnly` | `false` | Skip verification for routes whose HTML file did not change since the last run |
428
484
  | `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
429
485
  | `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
430
486
  | `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snappy-prerender",
3
- "version": "2.6.0-rc.2",
3
+ "version": "2.6.0-rc.3",
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",
@@ -68,11 +68,13 @@
68
68
  "vite": "7.3.6"
69
69
  },
70
70
  "workspaces": [
71
+ "tests/fixtures/react16-app",
72
+ "tests/fixtures/react17-app",
71
73
  "tests/fixtures/react18-app"
72
74
  ],
73
75
  "peerDependencies": {
74
76
  "beasties": ">=0.4.1",
75
- "react": ">=18.0.0",
77
+ "react": ">=16.8.0",
76
78
  "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
77
79
  },
78
80
  "peerDependenciesMeta": {
package/src/cli.js CHANGED
@@ -37,6 +37,7 @@ Options:
37
37
  --browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
38
38
  --storage-state <file> Playwright storage state for authenticated routes
39
39
  --no-verify Skip the hydration verification pass
40
+ --verify-changed-only Skip verification for routes whose HTML did not change
40
41
  --fail-on-hydration-error Fail the build when hydration errors are found
41
42
  --fail-on-rerender Fail when a route re-renders instead of hydrating
42
43
  --fail-on-page-error Fail when a page throws while it is being rendered
@@ -65,6 +66,7 @@ Options:
65
66
  --no-preconnect-third-party Disable preconnect hints
66
67
  --preload-images Add preload hints for images on the page
67
68
  --preload-manifest Write preload-manifest.json with Link header hints
69
+ --sitemap Write sitemap.xml from the rendered routes (needs metadata.siteUrl)
68
70
  --ignore-for-preload <names> File names excluded from the manifest, comma separated
69
71
  --cache-ajax-requests Expose captured JSON responses as window.snapStore
70
72
  --remove-scripts Strip every script tag from the output
@@ -73,6 +75,7 @@ Options:
73
75
  --external-scripts Write injected state scripts as same-origin files, for a strict CSP
74
76
  --csp <mode> off | strict: strict makes the output work under script-src 'self'
75
77
  --no-remove-blobs Keep blob stylesheet links instead of dropping them
78
+ --remove-attributes <names> Drop these attributes from the output, comma separated
76
79
  --ignore-https-errors Ignore TLS errors while rendering
77
80
  --browser-args <args> Extra browser launch arguments, comma separated
78
81
  --url <url> App URL to connect to when using --serve-cmd
@@ -108,6 +111,7 @@ async function main() {
108
111
  'browser-download-hash': { type: 'string' },
109
112
  'storage-state': { type: 'string' },
110
113
  'no-verify': { type: 'boolean' },
114
+ 'verify-changed-only': { type: 'boolean' },
111
115
  'fail-on-hydration-error': { type: 'boolean' },
112
116
  'fail-on-rerender': { type: 'boolean' },
113
117
  'fail-on-page-error': { type: 'boolean' },
@@ -136,6 +140,7 @@ async function main() {
136
140
  'no-preconnect-third-party': { type: 'boolean' },
137
141
  'preload-images': { type: 'boolean' },
138
142
  'preload-manifest': { type: 'boolean' },
143
+ sitemap: { type: 'boolean' },
139
144
  'ignore-for-preload': { type: 'string', multiple: true },
140
145
  'cache-ajax-requests': { type: 'boolean' },
141
146
  'remove-scripts': { type: 'boolean' },
@@ -143,6 +148,7 @@ async function main() {
143
148
  'async-scripts': { type: 'boolean' },
144
149
  'external-scripts': { type: 'boolean' },
145
150
  'no-remove-blobs': { type: 'boolean' },
151
+ 'remove-attributes': { type: 'string', multiple: true },
146
152
  'ignore-https-errors': { type: 'boolean' },
147
153
  'browser-args': { type: 'string', multiple: true },
148
154
  url: { type: 'string' },
@@ -207,6 +213,7 @@ function cliOptions({ values, positionals }) {
207
213
  }
208
214
  if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
209
215
  if (values['no-verify']) options.verify = false;
216
+ if (values['verify-changed-only']) options.verifyChangedOnly = true;
210
217
  if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
211
218
  if (values['fail-on-rerender']) options.failOnRerender = true;
212
219
  if (values['fail-on-page-error']) options.failOnPageError = true;
@@ -236,6 +243,7 @@ function cliOptions({ values, positionals }) {
236
243
  if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
237
244
  if (values['preload-images']) options.preloadImages = true;
238
245
  if (values['preload-manifest']) options.preloadManifest = true;
246
+ if (values.sitemap) options.sitemap = true;
239
247
  if (values['ignore-for-preload'] !== undefined) {
240
248
  options.ignoreForPreload = splitList(values['ignore-for-preload']);
241
249
  }
@@ -245,6 +253,9 @@ function cliOptions({ values, positionals }) {
245
253
  if (values['async-scripts']) options.asyncScriptTags = true;
246
254
  if (values['external-scripts']) options.externalScripts = true;
247
255
  if (values['no-remove-blobs']) options.removeBlobs = false;
256
+ if (values['remove-attributes'] !== undefined) {
257
+ options.removeAttributes = splitList(values['remove-attributes']);
258
+ }
248
259
  if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
249
260
  if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
250
261
  if (values.url !== undefined) options.url = values.url;
@@ -435,7 +446,9 @@ function metadataOptions(values) {
435
446
 
436
447
  /**
437
448
  * Loads a JS config file. An explicitly requested file must exist; the implicit
438
- * snappy.config.js is optional. The default export must be a plain options object.
449
+ * snappy.config.js is optional. The default export is either a plain options object or an
450
+ * async function that returns one, which is how routes are computed from a CMS, a sitemap or
451
+ * a file listing without a separate prebuild script.
439
452
  */
440
453
  async function loadConfigFile(explicitPath) {
441
454
  if (explicitPath === undefined) {
@@ -445,9 +458,17 @@ async function loadConfigFile(explicitPath) {
445
458
  throw new Error(`Config file not found: ${explicitPath}`);
446
459
  }
447
460
  const module = await import(pathToFileURL(explicitPath).href);
448
- const loaded = module.default ?? {};
461
+ const exported = module.default ?? {};
462
+ let loaded;
463
+ try {
464
+ loaded = typeof exported === 'function' ? await exported() : exported;
465
+ } catch (error) {
466
+ throw new Error(`Config file failed to load: ${error.message}`);
467
+ }
449
468
  if (typeof loaded !== 'object' || loaded === null || Array.isArray(loaded)) {
450
- throw new Error(`Config file must export a plain options object: ${explicitPath}`);
469
+ throw new Error(
470
+ `Config file must export a plain options object or a function returning one: ${explicitPath}`,
471
+ );
451
472
  }
452
473
  return loaded;
453
474
  }
@@ -27,7 +27,14 @@ const NULLABLE_KEYS = [
27
27
  'url',
28
28
  'serveCmd',
29
29
  ];
30
- const ARRAY_KEYS = ['include', 'exclude', 'allowedHosts', 'browserArgs', 'ignoreForPreload'];
30
+ const ARRAY_KEYS = [
31
+ 'include',
32
+ 'exclude',
33
+ 'allowedHosts',
34
+ 'browserArgs',
35
+ 'ignoreForPreload',
36
+ 'removeAttributes',
37
+ ];
31
38
 
32
39
  export const DEFAULTS = Object.freeze({
33
40
  sourceDir: 'dist',
@@ -68,6 +75,7 @@ export const DEFAULTS = Object.freeze({
68
75
  minifyHtml: false,
69
76
  minifyCss: false,
70
77
  removeBlobs: true,
78
+ removeAttributes: [],
71
79
  removeStyleTags: false,
72
80
  removeScriptTags: false,
73
81
  asyncScriptTags: false,
@@ -75,12 +83,14 @@ export const DEFAULTS = Object.freeze({
75
83
  preconnectThirdParty: true,
76
84
  preloadImages: false,
77
85
  preloadManifest: false,
86
+ sitemap: false,
78
87
  ignoreForPreload: ['service-worker.js'],
79
88
  cacheAjaxRequests: false,
80
89
  maxCachedBytes: 5 * 1024 * 1024,
81
90
  flatOutput: false,
82
91
  notFoundRoute: '/404',
83
92
  verify: true,
93
+ verifyChangedOnly: false,
84
94
  failOnHydrationError: false,
85
95
  failOnRerender: false,
86
96
  failOnPageError: false,
@@ -189,6 +199,7 @@ export function validateConfig(config) {
189
199
  stringList(config.allowedHosts, 'allowedHosts');
190
200
  stringList(config.browserArgs, 'browserArgs');
191
201
  stringList(config.ignoreForPreload, 'ignoreForPreload');
202
+ patternList(config.removeAttributes, 'removeAttributes');
192
203
  for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
193
204
  for (const key of ['userAgent', 'destination']) nullableString(key);
194
205
  for (const key of BOOLEAN_KEYS) booleanOption(key);
@@ -271,6 +282,9 @@ export function validateConfig(config) {
271
282
  throw new TypeError(`saveAs must be one of: ${SAVE_AS_CHOICES.join(', ')}`);
272
283
  }
273
284
  validateMetadata(config);
285
+ if (config.sitemap && !config.metadata?.siteUrl) {
286
+ throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
287
+ }
274
288
  }
275
289
 
276
290
  // The metadata keys the head component reads, so a typo is reported instead of ignored.
@@ -16,6 +16,7 @@ export function normaliseHtml(html, options = {}) {
16
16
  const document = parse(html);
17
17
  const stats = {
18
18
  removedElements: 0,
19
+ removedAttributes: 0,
19
20
  dedupedStyles: 0,
20
21
  hints: 0,
21
22
  textSeparators: 0,
@@ -41,6 +42,9 @@ function cleanChildren(parent, stats, seenStyles, inHead, options) {
41
42
  for (const child of children) {
42
43
  const childInHead = inHead || child.tagName === 'head';
43
44
  if (shouldRemove(child, stats, options)) continue;
45
+ if (child.attrs && options.removeAttributes?.length > 0) {
46
+ stripAttributes(child, options.removeAttributes, stats);
47
+ }
44
48
  if (childInHead && child.tagName === 'style' && dedupeStyle(child, stats, seenStyles)) continue;
45
49
  if (childInHead && child.tagName === 'title') stats.titleElements += 1;
46
50
  if (childInHead) collectMetadataOrigins(child, stats);
@@ -67,6 +71,22 @@ function createComment() {
67
71
  return { nodeName: '#comment', data: ' ', parentNode: null };
68
72
  }
69
73
 
74
+ // Drops attributes whose names the caller marked as volatile, such as the per-load ids a UI
75
+ // library writes into the DOM, so repeated runs produce identical output.
76
+ function stripAttributes(node, patterns, stats) {
77
+ const kept = node.attrs.filter(
78
+ (attribute) => !patterns.some((pattern) => matchesAttribute(attribute.name, pattern)),
79
+ );
80
+ if (kept.length === node.attrs.length) return;
81
+ stats.removedAttributes += node.attrs.length - kept.length;
82
+ node.attrs = kept;
83
+ }
84
+
85
+ // Reports whether an attribute name matches a string exactly or a RegExp pattern.
86
+ function matchesAttribute(name, pattern) {
87
+ return pattern instanceof RegExp ? pattern.test(name) : name === pattern;
88
+ }
89
+
70
90
  // Reports whether a node is removed for any of the configured reasons. The metadata script is
71
91
  // exempt: the head layer reads it on the client, so stripping it would lose the site defaults.
72
92
  function shouldRemove(node, stats, options) {
package/src/core/run.js CHANGED
@@ -18,6 +18,7 @@ import { renderRoute } from './render.js';
18
18
  import { crawlRoots, filterRoutes, normaliseRoute, toPublicPath, withinDepth } from './routes.js';
19
19
  import { createPool } from './scheduler.js';
20
20
  import { startCommandServer, startStaticServer } from './server.js';
21
+ import { buildSitemap, withSitemapDirective } from './sitemap.js';
21
22
  import { verifyRoutes } from './verify.js';
22
23
 
23
24
  /**
@@ -57,6 +58,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
57
58
  duplicateTitles: [],
58
59
  verification: null,
59
60
  preloadManifest: null,
61
+ sitemap: null,
60
62
  truncated: null,
61
63
  ok: false,
62
64
  durationMs: 0,
@@ -94,6 +96,10 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
94
96
  logNotFoundNotice(report, config, log);
95
97
  logDuplicateTitles(report, log);
96
98
 
99
+ if (config.sitemap) {
100
+ await writeSitemap({ outputDir, config, log, report });
101
+ }
102
+
97
103
  if (report.truncated) {
98
104
  log.error(
99
105
  `maxRoutes (${report.truncated.limit}) reached after ${report.truncated.routes} route(s); output is incomplete`,
@@ -115,13 +121,25 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
115
121
  'hydration verification is skipped with serveCmd: the command server decides which document it serves, so the written files cannot be checked',
116
122
  );
117
123
  }
118
- if (canVerify && !config.serveCmd && verifiable.length > 0) {
124
+ // With verifyChangedOnly, a route whose file was left untouched is not reloaded: the HTML
125
+ // and the bundle are the same as the last time it was verified.
126
+ const toVerify = config.verifyChangedOnly
127
+ ? verifiable.filter(
128
+ (route) => report.files.find((file) => file.route === route)?.status !== 'unchanged',
129
+ )
130
+ : verifiable;
131
+ const skipped = verifiable.length - toVerify.length;
132
+ if (canVerify && !config.serveCmd && skipped > 0) {
133
+ log.info(`Verification skipped for ${skipped} unchanged route(s), as verifyChangedOnly asks`);
134
+ }
135
+ if (canVerify && !config.serveCmd && toVerify.length > 0) {
119
136
  report.verification = await verifyRoutes({
120
137
  browser,
121
138
  origin: server.origin,
122
- routes: verifiable.sort(),
139
+ routes: toVerify.sort(),
123
140
  config,
124
141
  });
142
+ report.verification.skipped = skipped;
125
143
  logVerification(report.verification, config, log);
126
144
  }
127
145
  } finally {
@@ -360,6 +378,7 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
360
378
  removeScriptTags: config.removeScriptTags,
361
379
  asyncScriptTags: config.asyncScriptTags,
362
380
  removeBlobs: config.removeBlobs,
381
+ removeAttributes: config.removeAttributes,
363
382
  preconnectOrigins: config.preconnectThirdParty
364
383
  ? [...result.collector.thirdPartyOrigins].sort()
365
384
  : [],
@@ -369,12 +388,13 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
369
388
  });
370
389
  if (
371
390
  stats.removedElements > 0 ||
391
+ stats.removedAttributes > 0 ||
372
392
  stats.dedupedStyles > 0 ||
373
393
  stats.hints > 0 ||
374
394
  stats.textSeparators > 0
375
395
  ) {
376
396
  log.debug(
377
- ` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
397
+ ` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.removedAttributes} attribute(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
378
398
  );
379
399
  }
380
400
  if ((result.inlineCssSkipped ?? []).length > 0) {
@@ -452,6 +472,63 @@ async function writePreloadManifest({ manifestEntries, outputDir, config, log })
452
472
  return { file, routes: manifest.length };
453
473
  }
454
474
 
475
+ /**
476
+ * Writes sitemap.xml from the routes the run produced, and points an existing robots.txt at
477
+ * it. Every loc must be absolute, which is why the option needs metadata.siteUrl.
478
+ */
479
+ async function writeSitemap({ outputDir, config, log, report }) {
480
+ if (config.saveAs !== 'html') {
481
+ log.warn(
482
+ `sitemap is skipped when saveAs writes ${config.saveAs} files, as there are no pages to list`,
483
+ );
484
+ return;
485
+ }
486
+ const sitemap = buildSitemap({ files: report.files, config });
487
+ if (!sitemap) {
488
+ log.warn('sitemap is skipped: no pages were written');
489
+ return;
490
+ }
491
+
492
+ const siteUrl = config.metadata.siteUrl.replace(/\/$/, '');
493
+ const sitemapUrl = `${siteUrl}${toPublicPath('/sitemap.xml', config.base)}`;
494
+ const robots = await fs.readFile(path.join(outputDir, 'robots.txt'), 'utf8').catch(() => null);
495
+ const patched = robots === null ? null : withSitemapDirective(robots, sitemapUrl);
496
+
497
+ if (config.dryRun) {
498
+ report.files.push({
499
+ route: null,
500
+ file: 'sitemap.xml',
501
+ status: 'dry-run',
502
+ bytes: Buffer.byteLength(sitemap.contents),
503
+ });
504
+ report.sitemap = { file: 'sitemap.xml', routes: sitemap.routes, robotsUpdated: false };
505
+ log.info(`Dry run: would write sitemap.xml listing ${sitemap.routes} route(s)`);
506
+ return;
507
+ }
508
+
509
+ const written = await writeFileIfChanged({
510
+ dir: outputDir,
511
+ file: 'sitemap.xml',
512
+ contents: sitemap.contents,
513
+ });
514
+ report.files.push({ route: null, ...written });
515
+ report.sitemap = {
516
+ file: 'sitemap.xml',
517
+ routes: sitemap.routes,
518
+ robotsUpdated: patched !== null,
519
+ };
520
+ log.info(`Wrote sitemap.xml listing ${sitemap.routes} route(s)`);
521
+
522
+ if (robots === null) {
523
+ log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
524
+ return;
525
+ }
526
+ if (patched !== null) {
527
+ await writeFileIfChanged({ dir: outputDir, file: 'robots.txt', contents: patched });
528
+ log.info('Added the sitemap location to robots.txt');
529
+ }
530
+ }
531
+
455
532
  // Notes when a not-found route is configured but was never rendered, so no 404.html
456
533
  // was written and a static host would serve its own default page instead.
457
534
  function logNotFoundNotice(report, config, log) {
@@ -0,0 +1,62 @@
1
+ import { normaliseRoute, toPublicPath } from './routes.js';
2
+
3
+ const XML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
4
+
5
+ /**
6
+ * Builds the sitemap for the routes a run wrote. Locs are derived from the output file
7
+ * names, so a directory route becomes /about/ and a flat one becomes /about.html, then
8
+ * prefixed with the public base path. Returns null when there is no public site URL or
9
+ * nothing to list, so callers can skip the write entirely.
10
+ */
11
+ export function buildSitemap({ files, config, generatedAt = new Date() }) {
12
+ const siteUrl = config.metadata?.siteUrl?.replace(/\/$/, '');
13
+ if (!siteUrl) return null;
14
+ const notFound = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
15
+
16
+ const locs = new Set();
17
+ for (const file of files) {
18
+ if (!file.file.endsWith('.html')) continue;
19
+ if (file.route && notFound && normaliseRoute(file.route) === notFound) continue;
20
+ locs.add(`${siteUrl}${toPublicPath(publicPath(file.file), config.base)}`);
21
+ }
22
+ if (locs.size === 0) return null;
23
+
24
+ const lastmod = generatedAt.toISOString().slice(0, 10);
25
+ const urls = [...locs]
26
+ .sort()
27
+ .map(
28
+ (loc) =>
29
+ ` <url>\n <loc>${escapeXml(loc)}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
30
+ )
31
+ .join('\n');
32
+ return {
33
+ contents: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`,
34
+ routes: locs.size,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Maps an output file to the path a host serves it at: index.html is the root,
40
+ * about/index.html is /about/, and anything else keeps its name.
41
+ */
42
+ export function publicPath(file) {
43
+ const normalised = String(file).replaceAll('\\', '/');
44
+ if (normalised === 'index.html') return '/';
45
+ if (normalised.endsWith('/index.html')) return `/${normalised.slice(0, -'index.html'.length)}`;
46
+ return `/${normalised}`;
47
+ }
48
+
49
+ /**
50
+ * Appends the sitemap location to a robots.txt that exists but does not mention it yet,
51
+ * leaving a file that already points somewhere alone.
52
+ */
53
+ export function withSitemapDirective(robots, sitemapUrl) {
54
+ if (/^\s*sitemap:/im.test(robots)) return null;
55
+ const separator = robots.endsWith('\n') ? '' : '\n';
56
+ return `${robots}${separator}Sitemap: ${sitemapUrl}\n`;
57
+ }
58
+
59
+ // Escapes the characters that would break a sitemap element.
60
+ function escapeXml(value) {
61
+ return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char]);
62
+ }
@@ -7,6 +7,10 @@ const HYDRATION_PATTERNS = [
7
7
  /hydrat/i,
8
8
  /did not match/i,
9
9
  /server-rendered HTML/i,
10
+ // React 16 and 17 phrase their mismatch warnings differently and never use the
11
+ // minified codes, which only exist from React 18 on.
12
+ /did not expect server HTML/i,
13
+ /expected server HTML to contain/i,
10
14
  /react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
11
15
  /Minified React error #(418|419|421|422|423|424|425)\b/i,
12
16
  ];