snappy-prerender 2.6.0-rc.2
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/LICENSE +21 -0
- package/README.md +502 -0
- package/package.json +89 -0
- package/src/cli.js +476 -0
- package/src/core/blocking.js +28 -0
- package/src/core/browser.js +122 -0
- package/src/core/config.js +320 -0
- package/src/core/destination.js +45 -0
- package/src/core/form-state.js +23 -0
- package/src/core/index.js +2 -0
- package/src/core/init.js +136 -0
- package/src/core/inline-css.js +67 -0
- package/src/core/log.js +45 -0
- package/src/core/metadata-origin.js +39 -0
- package/src/core/minify.js +42 -0
- package/src/core/normalize.js +197 -0
- package/src/core/output.js +81 -0
- package/src/core/preload-manifest.js +28 -0
- package/src/core/render.js +228 -0
- package/src/core/resources.js +113 -0
- package/src/core/routes.js +150 -0
- package/src/core/run.js +523 -0
- package/src/core/runtime-styles.js +42 -0
- package/src/core/scheduler.js +83 -0
- package/src/core/server.js +216 -0
- package/src/core/settle.js +141 -0
- package/src/core/text-separators.js +30 -0
- package/src/core/verify.js +148 -0
- package/src/head/index.js +56 -0
- package/src/head/manager.js +230 -0
- package/src/head/tags.js +211 -0
- package/src/index.js +2 -0
- package/src/vite-plugin.js +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sayakmukherjee080
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# snappy-prerender
|
|
2
|
+
|
|
3
|
+
**Prerender your single-page app without rewriting it.** Point it at your build output, and every route is rendered in a real browser and written to disk as static HTML — content, titles, meta tags, canonical links and Open Graph tags included. Search engines and social crawlers get the full page; your users get the same app they had before.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/snappy-prerender)
|
|
6
|
+
[](https://www.npmjs.com/package/snappy-prerender)
|
|
7
|
+
[](https://github.com/sayakmukherjee080/snappy-prerender/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
- **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.**
|
|
11
|
+
- **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
|
+
|
|
13
|
+
## Who this is for
|
|
14
|
+
|
|
15
|
+
**Use it when** your app renders in the browser and you want the crawler-visible HTML to match: Create React App, Vite + React, plain SPAs, CMS-driven sites, marketing pages behind a client router.
|
|
16
|
+
|
|
17
|
+
**Skip it when** you already render HTML on the server — Next.js, Remix, Astro and friends do not need prerendering, and this tool will not improve on what they ship.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### Vite
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// vite.config.js
|
|
25
|
+
import { defineConfig } from 'vite';
|
|
26
|
+
import react from '@vitejs/plugin-react';
|
|
27
|
+
import snappy from 'snappy-prerender';
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [react(), snappy()],
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`vite build` now prerenders the output directory. Rendering failures fail the build; hydration is reported unless you opt into enforcement.
|
|
35
|
+
|
|
36
|
+
### Init wizard
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm install --save-dev snappy-prerender
|
|
40
|
+
npx snappy-prerender init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A few questions — build directory, base path, routes, site URL, site name, title template — then it writes `snappy.config.js` with only the options you set, and prints install, run and head-layer next steps. It reads your project first, so a Vite app is offered `dist`, a Create React App `build`, and the commands match your package runner.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npx snappy-prerender init --yes # accept every default, for a shell without a terminal
|
|
47
|
+
npx snappy-prerender init --force # replace an existing snappy.config.js
|
|
48
|
+
npx snappy-prerender init --metadata-site-url https://example.com # flags answer the questions
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### CLI
|
|
52
|
+
|
|
53
|
+
For any static output directory, Vite or not:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npm install --save-dev snappy-prerender
|
|
57
|
+
|
|
58
|
+
npx snappy-prerender dist
|
|
59
|
+
npx snappy-prerender dist --include /,/pricing,/blog/* --exclude /blog/drafts/*
|
|
60
|
+
npx snappy-prerender dist --block-third-party --allowed-hosts cms.example.com
|
|
61
|
+
npx snappy-prerender dist --metadata-site-url https://example.com --metadata-title-template '%s | Example'
|
|
62
|
+
npx snappy-prerender dist --inline-css critical --minify-html --preload-manifest
|
|
63
|
+
npx snappy-prerender dist --csp strict --inline-css critical
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Programmatic
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { prerender } from 'snappy-prerender/core';
|
|
70
|
+
|
|
71
|
+
const report = await prerender({ sourceDir: 'dist', include: ['/'] });
|
|
72
|
+
if (!report.ok) {
|
|
73
|
+
for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
|
|
74
|
+
console.error(failure.route, failure.hydrationErrors);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Inspired by react-snap
|
|
80
|
+
|
|
81
|
+
react-snap proved the idea that makes this approach attractive: you do not need a server, a second renderer or a rewrite to give crawlers real HTML — you need a browser, a router and a build step. It carried a generation of webpack-era SPAs and has been unmaintained since 2019.
|
|
82
|
+
|
|
83
|
+
`snappy-prerender` is a fresh implementation of that idea for current toolchains. It is not a fork: no react-snap code was reused. What changed is the machinery around the same core trick — Playwright instead of an old Puppeteer, Chrome or Edge from the machine you already have, Vite plugin support, a head and metadata layer with client-side updates, hydration verification that tells you how each page booted, and option names that say what they do.
|
|
84
|
+
|
|
85
|
+
If you are moving over, the [differences and migration table](#differences-from-react-snap) maps every option.
|
|
86
|
+
|
|
87
|
+
## What you get
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| **Static HTML per route** | `route/index.html` (or `route.html` with `flatOutput`), plus a `404.html` from your not-found route. Identical files are left untouched, so rebuilds and CI caches stay stable. |
|
|
92
|
+
| **Head and metadata** | A `Head` component writes per-route title, description, robots, canonical, Open Graph and Twitter tags — and keeps updating them on client-side navigation, exactly like a page-per-request site. |
|
|
93
|
+
| **Hydration report** | Every written page is reloaded with the real bundle and reported as `hydrated`, `re-rendered`, `undetected` or `unknown`, with React mismatch messages per route. |
|
|
94
|
+
| **A ready contract** | `window.__prerenderReady`, a selector, or network quiescence — whichever fits your app. |
|
|
95
|
+
| **Output optimisation** | Critical CSS, HTML/CSS minification, preconnect hints, image preload, a preload manifest, script and style surgery. |
|
|
96
|
+
| **CSS-in-JS support** | CSSOM-only styles (emotion, styled-components in speedy mode) and constructable stylesheets are folded back into the HTML. |
|
|
97
|
+
| **Ajax state replay** | Captured JSON and `window.snapSaveState()` are injected before the first script, so hydration sees the same data the prerender did. |
|
|
98
|
+
| **Guided setup** | `snappy-prerender init` asks the handful of questions that matter — build directory, base path, routes, site URL, site name, title template — and writes a `snappy.config.js` with your answers, detected from the project where it can. |
|
|
99
|
+
| **Any static build** | Base paths, `serveCmd` for apps that need a live API, screenshots, dry runs, bounded concurrency. |
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
1. Serves the built output on an ephemeral loopback port (or copies it to `destination` first).
|
|
104
|
+
2. Crawls same-origin links from the seed routes, rendering each page in a fresh browser context.
|
|
105
|
+
3. Waits for the app to settle: ready contract, quiet network, fonts, two animation frames.
|
|
106
|
+
4. Freezes CSS animations, optionally blocks third-party requests, records the resources the page used, and reconciles any head tags the app rendered.
|
|
107
|
+
5. Serialises the DOM, then post-processes it: removes the freeze style and `data-prerender-remove` elements, de-duplicates head styles, drops dead blob stylesheets, adds link hints, optionally inlines CSS, removes or marks scripts, optionally minifies, and notes metadata that points at the build server or ships more than one title.
|
|
108
|
+
6. Writes `route/index.html` (or a screenshot), leaving identical files untouched.
|
|
109
|
+
7. Reloads every written file with the real client bundle, reports React hydration errors, and records whether each route hydrated or re-rendered.
|
|
110
|
+
|
|
111
|
+
## Head and metadata
|
|
112
|
+
|
|
113
|
+
Per-route titles, descriptions, canonical links and Open Graph/Twitter tags come from a `Head` component the package ships. It writes into the document head while the route is mounted and reconciles on every render, so client-side navigation updates the head exactly like a site that renders a page per request — and the prerenderer captures whatever is there, because the head is part of the page it serialises.
|
|
114
|
+
|
|
115
|
+
```jsx
|
|
116
|
+
import { Head } from 'snappy-prerender/head';
|
|
117
|
+
|
|
118
|
+
<Head
|
|
119
|
+
title="Awards"
|
|
120
|
+
description="Awards presented by the society"
|
|
121
|
+
image="/share/awards.png"
|
|
122
|
+
type="article"
|
|
123
|
+
locale="en_GB"
|
|
124
|
+
robots="index,follow"
|
|
125
|
+
extra={[{ name: 'keywords', content: 'polymer' }, { rel: 'alternate', href: '/feed.xml' }]}
|
|
126
|
+
/>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Project-wide values come from the `metadata` option. The prerenderer injects them into every page, so the component composes absolute URLs from the public site rather than the build server:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
snappy({
|
|
133
|
+
metadata: {
|
|
134
|
+
siteUrl: 'https://example.com',
|
|
135
|
+
siteName: 'Example',
|
|
136
|
+
titleTemplate: '%s | Example',
|
|
137
|
+
defaultImage: '/share.png',
|
|
138
|
+
trailingSlash: 'never', // 'preserve' | 'always' | 'never'
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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`.
|
|
144
|
+
|
|
145
|
+
Rules worth knowing:
|
|
146
|
+
|
|
147
|
+
- `metadata` values are defaults; a `Head`'s own props win. When several Heads render, the later one in render order wins per tag, so a page overrides its layout.
|
|
148
|
+
- Tags are matched on the attribute a crawler reads, so an existing tag is updated in place rather than duplicated. Two tags with the same identity already in the document — a `name`, `property`, `http-equiv` or `rel` match — are reduced to one.
|
|
149
|
+
- A tag the head layer generated carries `data-snappy-head-created` beside `data-snappy-head`, so a later page load can tell it apart from one the app wrote.
|
|
150
|
+
- When no Head wants a tag any more, a generated one is removed and one the app wrote is restored to the value it had before the head layer touched it. That original is recorded into the page during prerendering, so a route change brings the template's fallback metadata back.
|
|
151
|
+
- The component renders nothing, so it cannot affect hydration of the page content.
|
|
152
|
+
- The title template applies unless the title already ends with the template's fixed part, or is the site name itself, so a title composed by hand is never suffixed twice.
|
|
153
|
+
- Absolute URLs are required for `og:url`, `og:image` and canonical. Without `metadata.siteUrl` they fall back to the browser origin, which during prerendering is the build server — the run warns when that happens. The `trailingSlash` convention applies to page URLs only; asset URLs are left alone.
|
|
154
|
+
- `metadata.base` overrides the base path used for absolute URLs and defaults to the `base` option.
|
|
155
|
+
- On React 19, React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template tag alone, so a page can end up with two titles. Use React's native metadata or this component, not both; the run warns when the output carries more than one `<title>`.
|
|
156
|
+
- Every metadata value has a CLI flag: `--metadata-site-url`, `--metadata-site-name`, `--metadata-title-template`, `--metadata-default-image`, `--metadata-trailing-slash`.
|
|
157
|
+
- `react` is an optional peer dependency, needed only for this entry point. Non-React code can call `setHead(id, props)` and `clearHead(id)` instead.
|
|
158
|
+
|
|
159
|
+
## Hydration verification
|
|
160
|
+
|
|
161
|
+
After writing the output, every route is loaded again with the real client bundle in a fresh browser context. The pass reports two things, and by default neither fails the build: the static HTML that crawlers read is already written and is unaffected by what the client does afterwards. Set `failOnHydrationError: true` (or pass `--fail-on-hydration-error`) when you want hydration treated as a build gate.
|
|
162
|
+
|
|
163
|
+
**Hydration errors.** React mismatch messages, text mismatches and the minified production codes (`#418`, `#423`, `#425`) are reported with the route and the message. They mean the client re-renders that page instead of adopting the markup, which costs the user some work on load but leaves the static output — search engines and social crawlers still read the prerendered HTML. Enforce them with `failOnHydrationError: true` when you want the stricter gate.
|
|
164
|
+
|
|
165
|
+
**How the app booted.** A probe installed before the app's scripts run remembers the first prerendered node in the container and checks whether it survived:
|
|
166
|
+
|
|
167
|
+
| Mode | Meaning |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `hydrated` | The app adopted the prerendered markup, which is the goal. |
|
|
170
|
+
| `re-rendered` | The app discarded it and rendered from scratch. |
|
|
171
|
+
| `undetected` | No React root was found on the container, so the mode could not be judged. |
|
|
172
|
+
| `unknown` | The prerendered node was never seen, for example a container outside the usual ids. |
|
|
173
|
+
|
|
174
|
+
`re-rendered` matters because an app calling `createRoot` instead of `hydrateRoot` throws the prerendered DOM away on boot — and because `createRoot` cannot raise a hydration error, nothing else would ever tell you. It is reported per route as `verification.routes[].mode` and summarised in `verification.modes`, with a warning in the log. Set `failOnRerender: true` to make it fail the build as well.
|
|
175
|
+
|
|
176
|
+
**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
|
+
|
|
178
|
+
## Ready contract
|
|
179
|
+
|
|
180
|
+
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:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// early in your app
|
|
184
|
+
window.__prerenderReady = false;
|
|
185
|
+
// when data has rendered
|
|
186
|
+
window.__prerenderReady = true;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The flag is only awaited when the app defines it. Alternatively use `waitFor` (a selector that must appear on every route) or `readySelector`.
|
|
190
|
+
|
|
191
|
+
## Detecting the prerenderer
|
|
192
|
+
|
|
193
|
+
Pages render with `userAgent: 'SnappyPrerender'`, so app code can branch:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
const isPrerender = navigator.userAgent === 'SnappyPrerender';
|
|
197
|
+
// e.g. skip analytics, or point API calls at a reachable host during the build
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Set `userAgent: null` to send the browser's default instead.
|
|
201
|
+
|
|
202
|
+
## Optimising the output
|
|
203
|
+
|
|
204
|
+
### Critical CSS
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
snappy({ inlineCss: 'inline' }) // inline every same-origin stylesheet, remove the links
|
|
208
|
+
snappy({ inlineCss: 'critical' }) // inline above-the-fold CSS, defer the rest (needs beasties)
|
|
209
|
+
snappy({ inlineCss: true }) // alias for 'inline'
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`'inline'` reads the stylesheet text from the live page, so blob-backed CSS-in-JS sheets are inlined too. Stylesheets that cannot be fetched, and non-screen media such as a print stylesheet, are left as links.
|
|
213
|
+
|
|
214
|
+
`'critical'` delegates to [beasties](https://github.com/danielroe/beasties):
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
npm install --save-dev beasties
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
It inlines the critical CSS and converts the remaining stylesheets to non-blocking loads using `media="print"` plus an inline `onload` handler, with a `<noscript>` fallback. Note that the inline handler is inline JavaScript: a strict `script-src` CSP without `unsafe-inline` will leave the deferred stylesheets unapplied. In that case use `'inline'` or a nonce-aware setup.
|
|
221
|
+
|
|
222
|
+
### Minification
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
snappy({ minifyHtml: true, minifyCss: true });
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`minifyHtml` uses html-minifier-terser with react-snap's defaults (whitespace collapsing, boolean attribute collapsing, attribute sorting). **Whitespace collapsing changes text nodes, which React notices during hydration** — the verifier is your safety net, and `{ collapseWhitespace: false }` is the usual fix. `minifyCss` is passed through to clean-css and also minifies CSS inlined into the HTML.
|
|
229
|
+
|
|
230
|
+
### Link hints
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
snappy({ preconnectThirdParty: true, preloadImages: true, preloadManifest: true });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `preconnectThirdParty` (default `true`) records every third-party origin the page tried to reach — including ones blocked by `blockThirdParty` — and adds `<link rel="preconnect">` for each.
|
|
237
|
+
- `preloadImages` adds `<link rel="preload" as="image">` for same-origin images the page loaded.
|
|
238
|
+
- `preloadManifest` writes `preload-manifest.json` into the output directory: for each route, a `Link` header value listing the scripts and stylesheets it used, filtered by `ignoreForPreload` (default `['service-worker.js']`). Hints and route keys carry the base path, so serve these as Early Hints or `Link` response headers as they are.
|
|
239
|
+
|
|
240
|
+
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
|
+
|
|
242
|
+
### Tag surgery
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
snappy({
|
|
246
|
+
asyncScriptTags: true, // mark external scripts async
|
|
247
|
+
removeScriptTags: false, // strip every script tag (page works without JS, hydration is lost)
|
|
248
|
+
removeStyleTags: false, // strip every style tag
|
|
249
|
+
removeBlobs: true, // drop stylesheet links pointing at dead blob: URLs (default on)
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The persisted metadata element is exempt from `removeScriptTags`; everything else is removed as asked.
|
|
254
|
+
|
|
255
|
+
### Strict CSP
|
|
256
|
+
|
|
257
|
+
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`:
|
|
258
|
+
|
|
259
|
+
| Piece | `off` | `strict` |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| Head defaults | Inert `<script type="application/json" data-snappy-meta>` — never executed, so `script-src` does not govern it | Same |
|
|
262
|
+
| Captured state | Inline script, injected before the bundle | Content-hashed same-origin file (`externalScripts: true`); routes sharing state share one file |
|
|
263
|
+
| Critical CSS swap | beasties' `media="print"` plus an inline `onload` handler | No swap handlers (`criticalCssPreload: false`); deferred stylesheets stay plain links |
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
snappy({ csp: 'strict' }); // the dial
|
|
267
|
+
snappy({ csp: 'strict', criticalCssPreload: 'media' }); // strict scripts, swap kept
|
|
268
|
+
snappy({ externalScripts: true }); // fine-tune one part on its own
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Two things the dial cannot remove, because they are what prerendered HTML is:
|
|
272
|
+
|
|
273
|
+
- Inlined CSS — critical CSS, `inlineCss: 'inline'`, and captured runtime styles — is `<style>` text, so `style-src` needs an allowance, the same as any prerendered HTML.
|
|
274
|
+
- The app bundle is an ordinary external script and is unaffected either way.
|
|
275
|
+
|
|
276
|
+
## Third-party requests
|
|
277
|
+
|
|
278
|
+
Third-party requests are **allowed by default**. Most React sites get their content from a CMS, headless API or a separate service origin, and blocking those during prerendering produces pages that render without data — so the default keeps them working, matching react-snap.
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
snappy({ blockThirdParty: false }); // default: allow everything
|
|
282
|
+
snappy({ blockThirdParty: true }); // abort every third-party request
|
|
283
|
+
snappy({ blockThirdParty: true, allowedHosts: ['cms.example.com'] }); // block all but the CMS
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Turning blocking on makes rendering deterministic and keeps analytics, ads and chat widgets from executing during a build. `allowedHosts` is the usual setting for a CMS-backed site: the API stays reachable while everything else is cut.
|
|
287
|
+
|
|
288
|
+
Preconnect hints are generated for **every** third-party origin the page contacts — including hosts you allowlist while blocking — so the deployed page can start those connections early. Set `preconnectThirdParty: false` to turn the hints off. The CLI equivalent for blocking is `--block-third-party`.
|
|
289
|
+
|
|
290
|
+
## CSS-in-JS
|
|
291
|
+
|
|
292
|
+
Emotion, styled-components, vanilla-extract, stitches, JSS and friends work out of the box as long as their styles end up as text inside a `<style>` tag. Three things need more than plain serialisation:
|
|
293
|
+
|
|
294
|
+
**CSSOM-only styles.** In production, emotion and styled-components v5 switch to `CSSStyleSheet.insertRule` ("speedy" mode), which puts rules in the CSS object model and leaves the style element empty in the DOM. Serialising the page would drop every one of those rules. `captureRuntimeStyles` (default `true`) copies them back into the element text, and also folds document-level constructable stylesheets (`document.adoptedStyleSheets`) into a `<style>` tag. Set it to `false` to leave stylesheets untouched.
|
|
295
|
+
|
|
296
|
+
**Blob-backed stylesheets.** Some setups create stylesheets as `blob:` URLs, which are dead once the page is gone. `removeBlobs` (default `true`) drops those links, and `inlineCss: 'inline'` inlines their content instead.
|
|
297
|
+
|
|
298
|
+
**Duplicate styles.** Runtimes that re-inject identical CSS during hydration leave repeated style tags behind. Identical `<style>` contents in the head are de-duplicated. A runtime may also re-insert its rules into a captured sheet on the client; that repeats identical CSS outside React's tree, so it changes nothing visually and cannot cause a hydration error.
|
|
299
|
+
|
|
300
|
+
Both `inlineCss` strategies work with CSS-in-JS output, and the hydration verifier is what catches a runtime that renders differently on the client than during prerender.
|
|
301
|
+
|
|
302
|
+
**Limitation:** content inside shadow roots is not part of `page.content()`, so a UI built on web components is captured as light DOM only. Use declarative shadow DOM or render those parts into the light DOM.
|
|
303
|
+
|
|
304
|
+
## Form state
|
|
305
|
+
|
|
306
|
+
Controlled inputs keep their state as DOM properties, which do not survive serialisation: a checkbox that is checked at runtime would come back unchecked, and a select would fall back to its first option, until the bundle hydrates. `captureFormState` (default `true`) writes `checked` and `selected` attributes to match the live DOM — the same thing React's own server rendering emits for controlled inputs — and clears the attributes when the property is false, so a stale default from the markup cannot leak through. Set it to `false` to leave form controls untouched.
|
|
307
|
+
|
|
308
|
+
`input.indeterminate` has no attribute and cannot be captured; restore it in the app after hydration if you rely on it.
|
|
309
|
+
|
|
310
|
+
## Async data and state
|
|
311
|
+
|
|
312
|
+
Hydration mismatches usually come from data that was present during prerender but missing on the client. Two mechanisms close that gap.
|
|
313
|
+
|
|
314
|
+
**Cached JSON.** With `cacheAjaxRequests: true`, every same-origin JSON response is captured and injected before the first script runs:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
snappy({ cacheAjaxRequests: true });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
// in your app, during hydration
|
|
322
|
+
const cached = window.snapStore?.['/api/items?page=2'];
|
|
323
|
+
if (cached) return cached;
|
|
324
|
+
return fetch('/api/items?page=2').then((response) => response.json());
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`window.snapStore` is keyed by the request path (including its query string), exactly as your app requested it. Responses larger than `maxCachedBytes` (default 5 MiB) are skipped, so one heavy endpoint cannot bloat every page.
|
|
328
|
+
|
|
329
|
+
**App state.** Define `window.snapSaveState` and whatever it returns is injected as globals before the first script:
|
|
330
|
+
|
|
331
|
+
```js
|
|
332
|
+
window.snapSaveState = () => ({ __APP_STATE__: store.getState() });
|
|
333
|
+
// becomes: window.__APP_STATE__ = {...}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Values are JSON-encoded with `<`, `>`, `/` and line separators escaped, so state data cannot break out of the script tag. Keys and values are both escaped.
|
|
337
|
+
|
|
338
|
+
## Screenshots
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
snappy({ saveAs: 'png', destination: 'build/screenshots' });
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`saveAs: 'png'` or `'jpeg'` captures a full-page screenshot per route instead of HTML (`index.png`, `about.png`, `blog/post.png`). Hydration verification is skipped, since there is no HTML to verify.
|
|
345
|
+
|
|
346
|
+
## Writing elsewhere
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
snappy({ destination: 'build/prerendered' });
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The built output is copied to the destination first, so assets sit beside the generated HTML, and the source directory stays untouched. A destination that already has files is reported before it is overwritten.
|
|
353
|
+
|
|
354
|
+
## A typical config file
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
// snappy.config.js — picked up by the CLI automatically
|
|
358
|
+
export default {
|
|
359
|
+
sourceDir: 'dist',
|
|
360
|
+
metadata: {
|
|
361
|
+
siteUrl: 'https://example.com',
|
|
362
|
+
siteName: 'Example',
|
|
363
|
+
titleTemplate: '%s | Example',
|
|
364
|
+
defaultImage: '/share.png',
|
|
365
|
+
},
|
|
366
|
+
cacheAjaxRequests: true,
|
|
367
|
+
preloadImages: true,
|
|
368
|
+
inlineCss: 'critical',
|
|
369
|
+
minifyHtml: true,
|
|
370
|
+
};
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Options
|
|
374
|
+
|
|
375
|
+
| Option | Default | Description |
|
|
376
|
+
| --- | --- | --- |
|
|
377
|
+
| `sourceDir` | `dist` | Built output directory |
|
|
378
|
+
| `destination` | `null` | Copy the output here and write results here instead of in place |
|
|
379
|
+
| `base` | `/` | Public base path |
|
|
380
|
+
| `include` | `['/']` | Seed routes and allowlist. Wildcards (`/blog/*`) filter discovered routes, only concrete paths are seeded. Supplying your own list switches discovery off, so the listed routes are all that is rendered; leave it unset to render the root and crawl everything linked from it |
|
|
381
|
+
| `exclude` | `[]` | Routes to skip, strings or RegExp |
|
|
382
|
+
| `crawl` | `true` | Follow same-origin links from seeds |
|
|
383
|
+
| `maxDepth` | `null` | Crawl depth limit, `null` is unlimited, `0` renders seeds only |
|
|
384
|
+
| `maxRoutes` | `null` | Stop after this many routes; hitting the cap marks the run incomplete |
|
|
385
|
+
| `concurrency` | derived | Parallel pages, derived from CPU count, capped at 8 |
|
|
386
|
+
| `timeout` | `30000` | Per-route timeout in ms |
|
|
387
|
+
| `quietPeriod` | `500` | Network must be idle this long before capture |
|
|
388
|
+
| `readyFlag` | `__prerenderReady` | Window flag awaited when defined |
|
|
389
|
+
| `readySelector` | `null` | Selector awaited when set |
|
|
390
|
+
| `waitFor` | `null` | Selector awaited on every route |
|
|
391
|
+
| `viewport` | `1280x720` | Browser viewport |
|
|
392
|
+
| `storageState` | `null` | Playwright storage state file for authenticated routes |
|
|
393
|
+
| `userAgent` | `SnappyPrerender` | User agent used while rendering, `null` for the browser default |
|
|
394
|
+
| `browser` | `auto` | `auto`, `chrome`, `msedge`, `chromium`, or a path to an executable |
|
|
395
|
+
| `browserDownload` | `true` | Allow the Chrome for Testing fallback download |
|
|
396
|
+
| `browserDownloadHash` | `null` | SHA-256 digest the fallback download must match, verified before use |
|
|
397
|
+
| `browserArgs` | `[]` | Extra browser launch arguments |
|
|
398
|
+
| `headless` | `true` | Run the browser headless |
|
|
399
|
+
| `ignoreHTTPSErrors` | `false` | Ignore TLS errors while rendering |
|
|
400
|
+
| `blockThirdParty` | `false` | Allow third-party requests so CMS and API calls work; set `true` to abort them |
|
|
401
|
+
| `allowedHosts` | `[]` | Hosts kept reachable when `blockThirdParty` is on |
|
|
402
|
+
| `freezeAnimations` | `true` | Neutralise animations and emulate reduced motion |
|
|
403
|
+
| `scrollToBottom` | `false` | Scroll through pages to trigger lazy content |
|
|
404
|
+
| `scrollStepDelay` | `100` | Delay between scroll steps when `scrollToBottom` is on |
|
|
405
|
+
| `captureRuntimeStyles` | `true` | Fold CSSOM-only styles and constructable stylesheets into the output |
|
|
406
|
+
| `captureFormState` | `true` | Sync `checked` and `selected` into the output markup |
|
|
407
|
+
| `csp` | `'off'` | `'off'` leaves the injected pieces inline; `'strict'` makes the output work under `script-src 'self'` by moving state to a same-origin file and dropping the CSS swap handlers |
|
|
408
|
+
| `inlineCss` | `false` | `'inline'`, `'critical'` (needs beasties) or `true` for `'inline'` |
|
|
409
|
+
| `criticalCssPreload` | `'media'` | Swap used for CSS deferred by `inlineCss: 'critical'`; `false` emits no swap handlers, for a strict script-src |
|
|
410
|
+
| `minifyHtml` | `false` | Minify output HTML, `true` for defaults or an html-minifier-terser options object |
|
|
411
|
+
| `minifyCss` | `false` | Minify CSS with clean-css, also applied to inlined CSS |
|
|
412
|
+
| `preconnectThirdParty` | `true` | Add preconnect hints for every third-party origin the page contacts |
|
|
413
|
+
| `preloadImages` | `false` | Add preload hints for same-origin images |
|
|
414
|
+
| `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints |
|
|
415
|
+
| `ignoreForPreload` | `['service-worker.js']` | File names excluded from the manifest |
|
|
416
|
+
| `cacheAjaxRequests` | `false` | Expose captured JSON responses as `window.snapStore` |
|
|
417
|
+
| `maxCachedBytes` | `5242880` | Largest JSON response cached per route, measured on the serialised body |
|
|
418
|
+
| `removeBlobs` | `true` | Drop stylesheet links pointing at dead `blob:` URLs |
|
|
419
|
+
| `removeStyleTags` | `false` | Strip every style tag from the output |
|
|
420
|
+
| `removeScriptTags` | `false` | Strip every script tag from the output |
|
|
421
|
+
| `asyncScriptTags` | `false` | Mark external scripts async |
|
|
422
|
+
| `externalScripts` | `false` | Write injected state scripts as content-hashed same-origin files instead of inline; set by `csp: 'strict'` |
|
|
423
|
+
| `flatOutput` | `false` | Write `about.html` instead of `about/index.html` |
|
|
424
|
+
| `notFoundRoute` | `/404` | Route emitted as `404.html`; a notice is logged when it is never prerendered |
|
|
425
|
+
| `saveAs` | `html` | `html`, `png` or `jpeg` |
|
|
426
|
+
| `metadata` | `null` | Defaults for the head component: `siteUrl`, `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
|
|
427
|
+
| `verify` | `true` | Run the hydration verification pass |
|
|
428
|
+
| `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
|
|
429
|
+
| `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
|
|
430
|
+
| `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
|
|
431
|
+
| `failOnError` | `true` | Fail the run on route render errors |
|
|
432
|
+
| `dryRun` | `false` | Render and report without writing |
|
|
433
|
+
| `logLevel` | `info` | `silent`, `error`, `warn`, `info`, `debug` |
|
|
434
|
+
| `serveCmd` | `null` | Command that starts the app server instead of static serving |
|
|
435
|
+
| `url` | `null` | App URL, required with `serveCmd` |
|
|
436
|
+
| `serveCmdTimeout` | `30000` | How long to wait for `serveCmd` to answer |
|
|
437
|
+
| `shutdownTimeout` | `5000` | Grace period before force-killing `serveCmd` |
|
|
438
|
+
|
|
439
|
+
Run `npx snappy-prerender --help` for the matching CLI flags.
|
|
440
|
+
|
|
441
|
+
## Programmatic API
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
import { prerender } from 'snappy-prerender/core';
|
|
445
|
+
|
|
446
|
+
const report = await prerender({ sourceDir: 'dist', include: ['/'] });
|
|
447
|
+
if (!report.ok) {
|
|
448
|
+
for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
|
|
449
|
+
console.error(failure.route, failure.hydrationErrors);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
The report exposes `routes`, `files` (with per-route `status`), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `duplicateTitles` (routes shipping more than one title), `verification` (per-route `mode` plus a `modes` summary), `preloadManifest`, `truncated`, and `ok`.
|
|
455
|
+
|
|
456
|
+
`snappy-prerender` exports `DEFAULTS`, `prerender` and `resolveConfig`; the default export is the Vite plugin.
|
|
457
|
+
|
|
458
|
+
## Differences from react-snap
|
|
459
|
+
|
|
460
|
+
Kept: crawling, include, concurrency, viewport, waitFor, executable path, external server, third-party skipping (now on by default), `userAgent`, `inlineCss`, `minifyHtml`, `minifyCss`, `cacheAjaxRequests`, `snapSaveState`, `preconnectThirdParty`, `preloadImages`, `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`, `destination`, `saveAs` (html/png/jpeg).
|
|
461
|
+
|
|
462
|
+
Changed: `http2PushManifest` became `preloadManifest` (browsers removed HTTP/2 push), `fixInsertRule` became `captureRuntimeStyles` and also covers constructable stylesheets, `fixFormFields` became `captureFormState`, `puppeteerArgs` became `browserArgs`, `puppeteerIgnoreHTTPSErrors` became `ignoreHTTPSErrors`, `skipThirdPartyRequests` became `blockThirdParty` with the same default of allowing them, `exclude` and `maxRoutes` are new, and `notFoundRoute` replaces the `/404` include convention.
|
|
463
|
+
|
|
464
|
+
Dropped: `sourceMaps` (declared but never referenced in react-snap's code), `fixWebpackChunksIssue` and `fixInsertRule` (webpack/Chrome-era workarounds), `port` (ephemeral loopback port instead), `puppeteer.cache`, and the preload polyfill (modulepreload is universally supported).
|
|
465
|
+
|
|
466
|
+
## Limitations
|
|
467
|
+
|
|
468
|
+
- **Build-time data is a snapshot.** Anything fetched during rendering is frozen into the HTML until the next build. Use `cacheAjaxRequests` and `snapSaveState` so the client replays the same data.
|
|
469
|
+
- **Hydration mismatches are your app's to fix.** The verifier tells you exactly which route and which React error. Common causes: `Date.now()`, `Math.random()`, `localStorage` reads during render, viewport-dependent markup, and CSS-in-JS that re-injects styles on hydration.
|
|
470
|
+
- **Markup that depends on load state cannot hydrate.** A component that swaps a placeholder for real content once an image or widget loads renders one thing during prerendering (where the network has settled) and another on the client's first render. The verifier reports those routes as `re-rendered`. Render deterministic markup instead — a plain `<img loading="lazy">` rather than a JS placeholder swap — or accept the re-render.
|
|
471
|
+
- **Authenticated routes need `storageState`.** Never prerender personal data into static HTML without deciding to.
|
|
472
|
+
- **Runtime-only routes** (heavy API dependence, real-time data) are better served by `serveCmd` or left client-rendered.
|
|
473
|
+
- Route query strings are ignored; routes are deduplicated without them.
|
|
474
|
+
- Routes whose decoded path contains dot segments or backslashes are skipped, so encoded traversal cannot create stray directories.
|
|
475
|
+
- Crawling is unbounded unless `maxRoutes` is set; hitting the cap marks the run as failed so incomplete output is never shipped silently. Which routes are cut is concurrency-dependent on a large site — narrow `include`/`exclude` instead when you need a deterministic set.
|
|
476
|
+
- **React 19 native metadata.** React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template title in place, so using both systems produces two titles in the output. Use one of them; the run warns when a page ships more than one `<title>`.
|
|
477
|
+
- If two routes would write the same file — such as `/` and `/index` with `flatOutput` — neither is written and the run fails with a collision error.
|
|
478
|
+
- Screenshots are viewport-width full-page captures, so they reflect the configured `viewport`, not a device matrix.
|
|
479
|
+
- Output files from routes that no longer exist are left in place; clean the output directory or use a fresh `destination` when route sets shrink.
|
|
480
|
+
|
|
481
|
+
## Requirements
|
|
482
|
+
|
|
483
|
+
- Node.js >= 20.19
|
|
484
|
+
- Chrome or Edge installed (detected automatically), or allow the one-time Chrome for Testing download fallback
|
|
485
|
+
- `beasties` only if you use `inlineCss: 'critical'` (optional peer dependency)
|
|
486
|
+
|
|
487
|
+
Browser resolution order: installed Chrome, installed Edge, a Playwright-managed browser, then a Chrome for Testing download from Google's version-pinned HTTPS bucket into `SNAPPY_BROWSER_CACHE_DIR` (defaults to the platform cache directory). Chrome for Testing publishes no checksum feed, so that archive is trusted on the strength of HTTPS and the pinned URL unless you set `browserDownloadHash`, which makes the download fail if the SHA-256 does not match. Set `browserDownload: false` to forbid downloads entirely.
|
|
488
|
+
|
|
489
|
+
## Contributing
|
|
490
|
+
|
|
491
|
+
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). For security reports, follow [SECURITY.md](SECURITY.md).
|
|
492
|
+
|
|
493
|
+
```sh
|
|
494
|
+
npm install
|
|
495
|
+
npm test # unit
|
|
496
|
+
npm run test:integration
|
|
497
|
+
npm run lint
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
## License
|
|
501
|
+
|
|
502
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snappy-prerender",
|
|
3
|
+
"version": "2.6.0-rc.2",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "sayakmukherjee080",
|
|
8
|
+
"homepage": "https://github.com/sayakmukherjee080/snappy-prerender#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/sayakmukherjee080/snappy-prerender.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/sayakmukherjee080/snappy-prerender/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"prerender",
|
|
18
|
+
"prerendering",
|
|
19
|
+
"ssg",
|
|
20
|
+
"static-site",
|
|
21
|
+
"spa",
|
|
22
|
+
"react",
|
|
23
|
+
"vite",
|
|
24
|
+
"hydration",
|
|
25
|
+
"seo",
|
|
26
|
+
"playwright"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.19.0"
|
|
30
|
+
},
|
|
31
|
+
"main": "./src/index.js",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./src/index.js",
|
|
34
|
+
"./core": "./src/core/index.js",
|
|
35
|
+
"./head": "./src/head/index.js",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"snappy-prerender": "./src/cli.js"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"sideEffects": false,
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node scripts/run-tests.mjs tests/unit",
|
|
49
|
+
"test:integration": "node scripts/run-tests.mjs tests/integration",
|
|
50
|
+
"lint": "biome check .",
|
|
51
|
+
"lint:fix": "biome check --write .",
|
|
52
|
+
"prepublishOnly": "npm run lint && npm test && npm run test:integration"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@puppeteer/browsers": "3.2.3",
|
|
56
|
+
"clean-css": "5.3.3",
|
|
57
|
+
"html-minifier-terser": "7.2.0",
|
|
58
|
+
"parse5": "8.0.0",
|
|
59
|
+
"playwright-core": "1.63.0",
|
|
60
|
+
"sirv": "3.0.2"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@biomejs/biome": "2.0.0",
|
|
64
|
+
"@vitejs/plugin-react": "5.0.0",
|
|
65
|
+
"beasties": "0.4.1",
|
|
66
|
+
"react": "19.2.0",
|
|
67
|
+
"react-dom": "19.2.0",
|
|
68
|
+
"vite": "7.3.6"
|
|
69
|
+
},
|
|
70
|
+
"workspaces": [
|
|
71
|
+
"tests/fixtures/react18-app"
|
|
72
|
+
],
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"beasties": ">=0.4.1",
|
|
75
|
+
"react": ">=18.0.0",
|
|
76
|
+
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"beasties": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"react": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"vite": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|