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 +57 -1
- package/package.json +4 -2
- package/src/cli.js +24 -3
- package/src/core/config.js +15 -1
- package/src/core/normalize.js +20 -0
- package/src/core/run.js +80 -3
- package/src/core/sitemap.js +62 -0
- package/src/core/verify.js +4 -0
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
|
[](https://www.npmjs.com/package/snappy-prerender)
|
|
6
|
+
[](https://www.npmjs.com/package/snappy-prerender)
|
|
6
7
|
[](https://www.npmjs.com/package/snappy-prerender)
|
|
7
8
|
[](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
|
|
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.
|
|
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": ">=
|
|
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
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -27,7 +27,14 @@ const NULLABLE_KEYS = [
|
|
|
27
27
|
'url',
|
|
28
28
|
'serveCmd',
|
|
29
29
|
];
|
|
30
|
-
const ARRAY_KEYS = [
|
|
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.
|
package/src/core/normalize.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|
package/src/core/verify.js
CHANGED
|
@@ -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
|
];
|