snappy-prerender 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- package/src/head/tags.js +349 -30
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Limitations
|
|
2
|
+
|
|
3
|
+
- **Build-time data is a snapshot.** Anything fetched during rendering is frozen into the
|
|
4
|
+
HTML until the next build. Use `cacheAjaxRequests` and `snapSaveState` so the client
|
|
5
|
+
replays the same data.
|
|
6
|
+
- **Hydration mismatches are your app's to fix.** The verifier tells you exactly which route
|
|
7
|
+
and which framework error. Common causes: `Date.now()`, `Math.random()`, `localStorage`
|
|
8
|
+
reads during render, viewport-dependent markup, and CSS-in-JS that re-injects styles on
|
|
9
|
+
hydration. Use `freezeTime` and `randomSeed` for deterministic build-time values, and
|
|
10
|
+
`hydration.allowRerender`/`skip` for genuinely live routes. A fixed clock is a build and
|
|
11
|
+
verification control only; production still uses real time. `performance.now()` is not
|
|
12
|
+
frozen because schedulers rely on it, and `crypto.randomUUID()` is not patched.
|
|
13
|
+
- **Markup that depends on load state cannot hydrate.** A component that swaps a placeholder
|
|
14
|
+
for real content once an image or widget loads renders one thing during prerendering
|
|
15
|
+
(where the network has settled) and another on the client's first render. The verifier
|
|
16
|
+
reports those routes as `re-rendered`. Render deterministic markup instead — a plain
|
|
17
|
+
`<img loading="lazy">` rather than a JS placeholder swap — or accept the re-render.
|
|
18
|
+
- **Authenticated routes need `storageState`.** Never prerender personal data into static
|
|
19
|
+
HTML without deciding to.
|
|
20
|
+
- **Path locale prefixes need app routing.** `locales.mode: 'path'` renders `/fr/...` pages,
|
|
21
|
+
but the app still has to route that prefix or use translated `routes`. The prerenderer
|
|
22
|
+
cannot rewrite the client router. Path mode expects one public origin; use single mode
|
|
23
|
+
for separate locale domains.
|
|
24
|
+
- **Hreflang needs distinct URLs.** A locale selected only by cookie or `localStorage` is
|
|
25
|
+
not a crawlable alternate URL. Use path prefixes, subdomains or separate deployments.
|
|
26
|
+
- **`seoLocale` does not create new URLs.** In single mode it produces one locale per
|
|
27
|
+
output. It seeds the prerender/verification browser and optionally writes a bootstrap,
|
|
28
|
+
but it does not create multiple locale URLs. `inject: false` localizes the HTML without
|
|
29
|
+
forcing the visitor's stored locale.
|
|
30
|
+
- **`forceHtmlLang` overrides app metadata.** Use it only when the app hardcodes a language
|
|
31
|
+
that disagrees with the SEO locale.
|
|
32
|
+
- **Runtime-only routes** (heavy API dependence, real-time data) are better served by
|
|
33
|
+
`serveCmd` or left client-rendered.
|
|
34
|
+
- Route query strings are ignored; routes are deduplicated without them.
|
|
35
|
+
- Routes whose decoded path contains dot segments or backslashes are skipped, so encoded
|
|
36
|
+
traversal cannot create stray directories.
|
|
37
|
+
- Crawling is unbounded unless `maxRoutes` is set; hitting the cap marks the run as failed
|
|
38
|
+
so incomplete output is never shipped silently. With `maxRoutes` or `routePriority`, the
|
|
39
|
+
crawl runs in deterministic waves and reports exactly which routes did not fit in
|
|
40
|
+
`truncated.droppedRoutes`.
|
|
41
|
+
- **React 19 native metadata.** React hoists `<title>`, `<meta>` and `<link>` rendered in
|
|
42
|
+
JSX. `Head` adopts a matching hoisted `<meta>` or `<link>` and updates it. A hoisted
|
|
43
|
+
`<title>` that already carries the Head text is left to React (so React can remove it on
|
|
44
|
+
navigation) and older template titles are removed. `titleAttributes` apply only when
|
|
45
|
+
`Head` owns the title. An app should still use one owner per tag: either native React 19
|
|
46
|
+
metadata or `Head`. The run warns when a page ships more than one `<title>`.
|
|
47
|
+
- **`Head` is client-side only.** It manages the live document head while the app runs and
|
|
48
|
+
the prerenderer captures the result. It does not provide `HelmetProvider`,
|
|
49
|
+
`helmetContext`, `HelmetData`, `renderStatic()`, streaming head extraction or a
|
|
50
|
+
server-rendering API. A server-rendered app still needs React 19 native metadata or a
|
|
51
|
+
server head manager.
|
|
52
|
+
- **`tags` and `noscript.html` are trusted input.** Script and style text is escaped so it
|
|
53
|
+
cannot close its own element, but a `noscript` value is written as inner HTML exactly as
|
|
54
|
+
supplied. Never pass user-generated content there.
|
|
55
|
+
- **Attributes belong to the mounted `Head`.** html, body and title attributes are restored
|
|
56
|
+
when no Head wants them, including after client navigation and prerendered hydration. A
|
|
57
|
+
value written outside the head layer while a Head owns the same attribute is overwritten
|
|
58
|
+
on the next render.
|
|
59
|
+
- If two routes would write the same file — such as `/` and `/index` with `flatOutput` —
|
|
60
|
+
neither is written and the run fails with a collision error.
|
|
61
|
+
- Screenshots are viewport-width full-page captures, so they reflect the configured
|
|
62
|
+
`viewport`, not a device matrix. Identical screenshots are left untouched.
|
|
63
|
+
- **Output files from routes that no longer exist** are left in place unless
|
|
64
|
+
`pruneStaleRoutes` is enabled, which deletes only files listed in the previous run's
|
|
65
|
+
`.snappy-manifest.json`.
|
|
66
|
+
- **Dead extensionless links** are indistinguishable from SPA routes to the static server,
|
|
67
|
+
so a missing page can render the shell or the app's catch-all 404. Duplicate output is
|
|
68
|
+
reported; set `failOnDuplicateContent` to enforce it, and use an app-level 404 marker
|
|
69
|
+
where possible.
|
|
70
|
+
- **Metadata pointing at the build server** is removed by default
|
|
71
|
+
(`stripBuildOriginMetadata: true`) and reported. Set `metadata.siteUrl` so canonical and
|
|
72
|
+
social URLs survive.
|
|
73
|
+
- **`inlineCss`** rebases relative font and image URLs against each stylesheet. A sheet that
|
|
74
|
+
uses `@import` is left as a link because concatenating it would change the cascade.
|
|
75
|
+
- **`verifyChangedOnly` compares the generated HTML only.** If a bundle keeps the same
|
|
76
|
+
filename while its contents change, that route's HTML is unchanged and its verification
|
|
77
|
+
is skipped; content-hashed asset names (Vite and CRA defaults) make the HTML change as
|
|
78
|
+
well.
|
|
79
|
+
- **Destination directories are merged unless `cleanDestination` is on.** That option
|
|
80
|
+
empties the destination first; `pruneStaleRoutes` separately removes pages the run itself
|
|
81
|
+
generated.
|
|
82
|
+
- **`removeStyleTags`** cannot be combined with `inlineCss: 'inline'` or `true`; the config
|
|
83
|
+
is rejected instead of silently shipping pages without CSS.
|
|
84
|
+
- **CSP `hash` mode cannot cover inline event handlers or `style=""` attributes.** Move
|
|
85
|
+
those styles into a `<style>` element or a stylesheet; the audit warns when attributes are
|
|
86
|
+
present.
|
|
87
|
+
- **CSP `strict` does not rewrite `Head` `tags`.** An inline script supplied through
|
|
88
|
+
`tags` is the app's own policy decision; give it a `nonce` the host policy allows or
|
|
89
|
+
externalise it with `src`. The package only externalises its own captured state and SEO
|
|
90
|
+
locale bootstrap.
|
|
91
|
+
- **`reuseVerificationContext` shares browser storage between routes.** Use it only for
|
|
92
|
+
stateless apps; authenticated routes can see cookies or storage from another route.
|
|
93
|
+
- **Failure traces can contain request data.** `traceOnError` is opt-in and writes a
|
|
94
|
+
Playwright archive under `snappy-traces/` in the output directory.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Migrating from react-snap
|
|
2
|
+
|
|
3
|
+
`snappy-prerender init` reads a `reactSnap` block from `package.json` and uses it as the
|
|
4
|
+
wizard's defaults, so an existing project can move without retyping its settings. The
|
|
5
|
+
migration table is printed after the config is written, together with any ignored or
|
|
6
|
+
unknown keys.
|
|
7
|
+
|
|
8
|
+
## Option mapping
|
|
9
|
+
|
|
10
|
+
| react-snap | snappy-prerender |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `source` | `sourceDir` |
|
|
13
|
+
| `puppeteerArgs` | `browserArgs` |
|
|
14
|
+
| `puppeteerIgnoreHTTPSErrors` | `ignoreHTTPSErrors` |
|
|
15
|
+
| `skipThirdPartyRequests` | `blockThirdParty` |
|
|
16
|
+
| `fixInsertRule` | `captureRuntimeStyles` |
|
|
17
|
+
| `fixFormFields` | `captureFormState` |
|
|
18
|
+
| `http2PushManifest` | `preloadManifest` |
|
|
19
|
+
|
|
20
|
+
These keep their name: `include`, `exclude`, `crawl`, `concurrency`, `timeout`, `waitFor`,
|
|
21
|
+
`inlineCss`, `minifyHtml`, `minifyCss`, `cacheAjaxRequests`, `preconnectThirdParty`,
|
|
22
|
+
`preloadImages`, `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`,
|
|
23
|
+
`userAgent`, `viewport`, `saveAs`, `destination`.
|
|
24
|
+
|
|
25
|
+
Ignored, with a printed explanation: `port` (the prerenderer serves the build itself on an
|
|
26
|
+
ephemeral loopback port) and `sourceMaps` (declared by react-snap but never read).
|
|
27
|
+
|
|
28
|
+
## Other differences
|
|
29
|
+
|
|
30
|
+
Kept: crawling, include, concurrency, viewport, waitFor, executable path, external server,
|
|
31
|
+
third-party skipping (now on by default), `userAgent`, `inlineCss`, `minifyHtml`,
|
|
32
|
+
`minifyCss`, `cacheAjaxRequests`, `snapSaveState`, `preconnectThirdParty`, `preloadImages`,
|
|
33
|
+
`removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`, `destination`,
|
|
34
|
+
`saveAs` (html/png/jpeg).
|
|
35
|
+
|
|
36
|
+
Changed: `http2PushManifest` became `preloadManifest` (browsers removed HTTP/2 push),
|
|
37
|
+
`fixInsertRule` became `captureRuntimeStyles` and also covers constructable stylesheets,
|
|
38
|
+
`fixFormFields` became `captureFormState`, `puppeteerArgs` became `browserArgs`,
|
|
39
|
+
`puppeteerIgnoreHTTPSErrors` became `ignoreHTTPSErrors`, `skipThirdPartyRequests` became
|
|
40
|
+
`blockThirdParty` with the same default of allowing them, `exclude` and `maxRoutes` are
|
|
41
|
+
new, and `notFoundRoute` replaces the `/404` include convention.
|
|
42
|
+
|
|
43
|
+
Dropped: `sourceMaps`, `fixWebpackChunksIssue` and `fixInsertRule` (webpack/Chrome-era
|
|
44
|
+
workarounds), `port`, `puppeteer.cache`, and the preload polyfill (modulepreload is
|
|
45
|
+
universally supported).
|
package/docs/report.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Report and audit
|
|
2
|
+
|
|
3
|
+
`reportPath` (CLI `--report <file>`) writes a versioned JSON document after the run. The
|
|
4
|
+
file is written through a temporary file, so a reader never sees a partial report, and it
|
|
5
|
+
is skipped in `--dry-run`.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"version": 1,
|
|
10
|
+
"generatedAt": "2026-10-28T00:00:00.000Z",
|
|
11
|
+
"durationMs": 0,
|
|
12
|
+
"timings": { "render": 0, "write": 0, "artifacts": 0, "verify": 0, "audit": 0 },
|
|
13
|
+
"ok": true,
|
|
14
|
+
"clock": { "fixedTime": 0, "timezone": "UTC" },
|
|
15
|
+
"locales": {
|
|
16
|
+
"mode": "path",
|
|
17
|
+
"default": "en",
|
|
18
|
+
"active": null,
|
|
19
|
+
"rendered": { "en": 0, "fr": 0 }
|
|
20
|
+
},
|
|
21
|
+
"seoLocale": {
|
|
22
|
+
"code": "fr",
|
|
23
|
+
"source": "both",
|
|
24
|
+
"inject": true,
|
|
25
|
+
"script": "snappy-seo-locale-abc123.js",
|
|
26
|
+
"scripts": ["snappy-seo-locale-abc123.js"]
|
|
27
|
+
},
|
|
28
|
+
"summary": { "routes": 0, "files": 0, "errors": 0, "warnings": 0 },
|
|
29
|
+
"verification": {
|
|
30
|
+
"ok": true,
|
|
31
|
+
"modes": { "hydrated": 0, "re-rendered": 0, "undetected": 0, "unknown": 0 },
|
|
32
|
+
"frameworks": { "react": 0 },
|
|
33
|
+
"skipped": 0,
|
|
34
|
+
"policySkipped": 0,
|
|
35
|
+
"allowed": 0,
|
|
36
|
+
"allowedRerender": 0,
|
|
37
|
+
"errors": []
|
|
38
|
+
},
|
|
39
|
+
"routes": [
|
|
40
|
+
{
|
|
41
|
+
"route": "/",
|
|
42
|
+
"locale": "en",
|
|
43
|
+
"order": 0,
|
|
44
|
+
"file": "index.html",
|
|
45
|
+
"status": "written",
|
|
46
|
+
"bytes": 0,
|
|
47
|
+
"depth": 0,
|
|
48
|
+
"attempts": 1,
|
|
49
|
+
"httpStatus": 200,
|
|
50
|
+
"finalUrl": "http://127.0.0.1:1234/",
|
|
51
|
+
"mode": "hydrated",
|
|
52
|
+
"framework": "react",
|
|
53
|
+
"empty": false,
|
|
54
|
+
"notFound": false,
|
|
55
|
+
"noindex": false,
|
|
56
|
+
"meta": {
|
|
57
|
+
"title": "",
|
|
58
|
+
"description": "",
|
|
59
|
+
"canonical": "",
|
|
60
|
+
"robots": "",
|
|
61
|
+
"lang": "en",
|
|
62
|
+
"dir": null,
|
|
63
|
+
"ogLocale": "en_US",
|
|
64
|
+
"h1Count": 1,
|
|
65
|
+
"alternates": [],
|
|
66
|
+
"localeUrl": null,
|
|
67
|
+
"images": [],
|
|
68
|
+
"inlineStyleAttributes": 0
|
|
69
|
+
},
|
|
70
|
+
"hydrationErrors": [],
|
|
71
|
+
"ignoredHydrationErrors": [],
|
|
72
|
+
"hydrationPolicy": "strict",
|
|
73
|
+
"hydrationAllowed": false,
|
|
74
|
+
"pageErrors": [],
|
|
75
|
+
"consoleErrors": [],
|
|
76
|
+
"failedRequests": [],
|
|
77
|
+
"firstMismatch": null
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"audit": { "errors": [], "warnings": [], "linksChecked": 0 },
|
|
81
|
+
"sitemap": null,
|
|
82
|
+
"preloadManifest": null,
|
|
83
|
+
"truncated": null,
|
|
84
|
+
"notFoundRoutes": [],
|
|
85
|
+
"duplicateOutputs": [],
|
|
86
|
+
"routeSources": [],
|
|
87
|
+
"cspSkipped": [],
|
|
88
|
+
"traces": []
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The document never includes the full configuration, `storageState`, captured AJAX state,
|
|
93
|
+
cookies, tokens, request bodies or full asset URLs containing credentials. It does include
|
|
94
|
+
non-secret summaries of the fixed clock, locale mode and SEO locale source. URLs in the JSON
|
|
95
|
+
are reduced to their origin and path, and the configured `storageState` path is redacted
|
|
96
|
+
wherever it appears in a message. `order` is the crawl acceptance order; with
|
|
97
|
+
`maxRoutes` or `routePriority` it follows the deterministic wave order rather than the
|
|
98
|
+
alphabetical write order.
|
|
99
|
+
|
|
100
|
+
## Audit rules
|
|
101
|
+
|
|
102
|
+
The audit runs by default and reports findings without failing the build. Set
|
|
103
|
+
`failOnAuditError: true` (CLI `--fail-on-audit-error`) to make `ok` false when an error is
|
|
104
|
+
present. `auditIgnoreLinks` (CLI `--audit-ignore-links`) excludes known dynamic links.
|
|
105
|
+
|
|
106
|
+
Errors:
|
|
107
|
+
|
|
108
|
+
- `route-error` — a route failed while rendering, or its output could not be written.
|
|
109
|
+
- `route-cap` — `maxRoutes` stopped the crawl before every discovered route was accepted.
|
|
110
|
+
- `empty-page` — a route rendered no app content.
|
|
111
|
+
- `duplicate-output` — two routes produced byte-identical HTML.
|
|
112
|
+
- `broken-internal-link` — an internal link with no written route or output file.
|
|
113
|
+
- `duplicate-title` — two routes share a title.
|
|
114
|
+
- `multiple-titles` — a page ships more than one document `<title>`.
|
|
115
|
+
- `hreflang-invalid` — an alternate has an invalid language code.
|
|
116
|
+
- `hreflang-duplicate` — the same hreflang appears more than once on a page.
|
|
117
|
+
- `hreflang-missing-return` — same-build alternates do not link back to each other.
|
|
118
|
+
- `sitemap-missing-page` — an indexable page is absent from the sitemap.
|
|
119
|
+
- `sitemap-unknown-page` — the sitemap lists a URL with no indexable page.
|
|
120
|
+
- `sitemap-noindex-page` — the sitemap lists a noindex or 404 page.
|
|
121
|
+
|
|
122
|
+
Warnings:
|
|
123
|
+
|
|
124
|
+
- `missing-title`, `missing-description`, `missing-canonical`.
|
|
125
|
+
- `duplicate-description`.
|
|
126
|
+
- `heading-count` — zero or more than one `<h1>`.
|
|
127
|
+
- `hreflang-missing-self`, `hreflang-missing-x-default`.
|
|
128
|
+
- `hreflang-canonical-mismatch`, `html-lang-missing`, `html-lang-mismatch`,
|
|
129
|
+
`og-locale-missing`.
|
|
130
|
+
- `canonical-mismatch` — canonical differs from the expected public URL.
|
|
131
|
+
- `build-origin-metadata` — a tag still points at the build server.
|
|
132
|
+
- `orphan-page` — an indexable page no written page links to.
|
|
133
|
+
- `inline-style-attributes` — `csp: 'hash'` cannot cover `style=""` attributes.
|
|
134
|
+
- `existing-csp-meta` — a CSP meta already existed, so hashes were not injected.
|
|
135
|
+
- `csp-console-violation` — the browser reported a request blocked by the generated policy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snappy-prerender",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Drop-in prerendering for existing single-page apps. Renders your built app in a real browser, writes static HTML per route, reports React hydration mismatches, and optimises the output with critical CSS, link hints and cached AJAX state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
43
|
+
"assets",
|
|
44
|
+
"docs",
|
|
43
45
|
"CHANGELOG.md",
|
|
44
46
|
"LICENSE",
|
|
45
47
|
"README.md"
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
"scripts": {
|
|
51
53
|
"test": "node scripts/run-tests.mjs tests/unit",
|
|
52
54
|
"test:integration": "node scripts/run-tests.mjs tests/integration",
|
|
55
|
+
"benchmark": "node scripts/benchmark.mjs",
|
|
53
56
|
"lint": "biome check .",
|
|
54
57
|
"lint:fix": "biome check --write .",
|
|
55
58
|
"prepublishOnly": "npm run lint && npm test && npm run test:integration"
|
package/src/cli-init.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { resolveConfig } from './core/config.js';
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_FILE,
|
|
6
|
+
configValuesFromAnswers,
|
|
7
|
+
detectProjectDefaults,
|
|
8
|
+
PACKAGE_NAME,
|
|
9
|
+
parseRouteList,
|
|
10
|
+
renderConfigFile,
|
|
11
|
+
writeConfigFile,
|
|
12
|
+
} from './core/init.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Setup wizard: asks for the handful of values that decide what gets prerendered and what the
|
|
16
|
+
* metadata says, then writes snappy.config.js. Flags cover every question, so
|
|
17
|
+
* `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
|
|
18
|
+
* terminal.
|
|
19
|
+
*/
|
|
20
|
+
export async function runInit({ values, flags, log, version }) {
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
if (!values.force && fs.existsSync(CONFIG_FILE)) {
|
|
23
|
+
throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
|
|
24
|
+
}
|
|
25
|
+
if (!values.yes && !process.stdin.isTTY) {
|
|
26
|
+
throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const detected = await detectProjectDefaults(cwd);
|
|
30
|
+
const defaults = defaultsFromFlags(detected, flags);
|
|
31
|
+
const answers = values.yes ? defaults : await askAnswers(defaults, log);
|
|
32
|
+
const cleaned = validatedAnswers(answers, log);
|
|
33
|
+
// Validate what will be written before touching an existing config file.
|
|
34
|
+
resolveConfig(configValuesFromAnswers(cleaned));
|
|
35
|
+
|
|
36
|
+
await writeConfigFile({
|
|
37
|
+
dir: cwd,
|
|
38
|
+
contents: renderConfigFile(cleaned),
|
|
39
|
+
force: values.force === true,
|
|
40
|
+
});
|
|
41
|
+
log.success(`Wrote ${CONFIG_FILE}`);
|
|
42
|
+
if (detected.reactSnap) printReactSnapMapping(detected.reactSnap, log);
|
|
43
|
+
printNextSteps({ detected, log, tag: version.includes('-') ? '@next' : '' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Turns the flags the CLI already understands into the wizard's starting answers.
|
|
47
|
+
function defaultsFromFlags(detected, flags) {
|
|
48
|
+
const mapped = detected.reactSnap?.values ?? {};
|
|
49
|
+
// Options the wizard renders itself stay out of the generic list.
|
|
50
|
+
const handled = new Set(['sourceDir', 'include', 'crawl', 'csp', 'externalScripts']);
|
|
51
|
+
const extraOptions = Object.fromEntries(
|
|
52
|
+
Object.entries(mapped).filter(([key]) => !handled.has(key) && key !== 'criticalCssPreload'),
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
...mapped,
|
|
56
|
+
sourceDir: flags.sourceDir ?? mapped.sourceDir ?? detected.sourceDir,
|
|
57
|
+
base: flags.base ?? detected.base ?? '/',
|
|
58
|
+
include: includeList(flags.include ?? mapped.include),
|
|
59
|
+
crawl: flags.crawl ?? mapped.crawl ?? true,
|
|
60
|
+
siteUrl: flags.metadata?.siteUrl ?? '',
|
|
61
|
+
siteName: flags.metadata?.siteName ?? '',
|
|
62
|
+
titleTemplate: flags.metadata?.titleTemplate ?? '',
|
|
63
|
+
defaultImage: flags.metadata?.defaultImage ?? '',
|
|
64
|
+
trailingSlash: flags.metadata?.trailingSlash ?? '',
|
|
65
|
+
csp: flags.csp ?? 'off',
|
|
66
|
+
externalScripts: flags.externalScripts,
|
|
67
|
+
criticalCssPreload: flags.criticalCssPreload ?? mapped.criticalCssPreload,
|
|
68
|
+
extraOptions,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Asks every question in order, closing the terminal interface whatever the outcome.
|
|
73
|
+
async function askAnswers(defaults, log) {
|
|
74
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
75
|
+
try {
|
|
76
|
+
log.info('A few questions, then a config file with only the options you set.');
|
|
77
|
+
return {
|
|
78
|
+
sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
|
|
79
|
+
base: await ask(rl, 'Public base path', defaults.base),
|
|
80
|
+
include: parseRouteList(
|
|
81
|
+
await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
|
|
82
|
+
),
|
|
83
|
+
crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
|
|
84
|
+
siteUrl: await ask(
|
|
85
|
+
rl,
|
|
86
|
+
'Public site URL for canonical and og:url, blank to skip',
|
|
87
|
+
defaults.siteUrl,
|
|
88
|
+
),
|
|
89
|
+
siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
|
|
90
|
+
titleTemplate: await ask(
|
|
91
|
+
rl,
|
|
92
|
+
"Title template such as '%s | Example', blank to skip",
|
|
93
|
+
defaults.titleTemplate,
|
|
94
|
+
),
|
|
95
|
+
csp: (await askYesNo(
|
|
96
|
+
rl,
|
|
97
|
+
"Serve a strict CSP (script-src 'self', no inline scripts)?",
|
|
98
|
+
defaults.csp === 'strict' || defaults.csp === 'hash',
|
|
99
|
+
))
|
|
100
|
+
? defaults.csp === 'hash'
|
|
101
|
+
? 'hash'
|
|
102
|
+
: 'strict'
|
|
103
|
+
: 'off',
|
|
104
|
+
defaultImage: defaults.defaultImage,
|
|
105
|
+
trailingSlash: defaults.trailingSlash,
|
|
106
|
+
extraOptions: defaults.extraOptions ?? {},
|
|
107
|
+
};
|
|
108
|
+
} finally {
|
|
109
|
+
rl.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Asks one question, showing the value Enter will accept.
|
|
114
|
+
async function ask(rl, question, fallback) {
|
|
115
|
+
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
116
|
+
return answer.length > 0 ? answer : fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Asks a yes/no question, treating Enter as the default.
|
|
120
|
+
async function askYesNo(rl, question, fallback) {
|
|
121
|
+
const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
|
|
122
|
+
.trim()
|
|
123
|
+
.toLowerCase();
|
|
124
|
+
if (answer.length === 0) return fallback;
|
|
125
|
+
return answer === 'y' || answer === 'yes';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Drops values that would make a broken config, so a typo becomes a warning instead.
|
|
129
|
+
function validatedAnswers(answers, log) {
|
|
130
|
+
const cleaned = { ...answers };
|
|
131
|
+
if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
|
|
132
|
+
log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
|
|
133
|
+
cleaned.siteUrl = '';
|
|
134
|
+
}
|
|
135
|
+
if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
|
|
136
|
+
log.warn('ignoring the title template: it has no %s placeholder');
|
|
137
|
+
cleaned.titleTemplate = '';
|
|
138
|
+
}
|
|
139
|
+
cleaned.include = parseRouteList(
|
|
140
|
+
Array.isArray(cleaned.include) ? cleaned.include.join(',') : (cleaned.include ?? '/'),
|
|
141
|
+
);
|
|
142
|
+
return cleaned;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Accepts the array, comma-separated string or absent include value from every source.
|
|
146
|
+
function includeList(value) {
|
|
147
|
+
if (Array.isArray(value)) return value;
|
|
148
|
+
if (typeof value === 'string') return parseRouteList(value);
|
|
149
|
+
return ['/'];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Prints what the wizard carried over from a legacy react-snap block, and what it did not.
|
|
153
|
+
function printReactSnapMapping(reactSnap, log) {
|
|
154
|
+
log.info('');
|
|
155
|
+
log.info('react-snap migration:');
|
|
156
|
+
if (reactSnap.mapping.length === 0) log.info(' no options carried over');
|
|
157
|
+
for (const { from, to, value } of reactSnap.mapping) {
|
|
158
|
+
log.info(` reactSnap.${from} -> ${to} = ${JSON.stringify(value)}`);
|
|
159
|
+
}
|
|
160
|
+
for (const { key, reason } of reactSnap.ignored) {
|
|
161
|
+
log.warn(` ignored reactSnap.${key}: ${reason}`);
|
|
162
|
+
}
|
|
163
|
+
for (const key of reactSnap.unknown) {
|
|
164
|
+
log.warn(` unknown reactSnap.${key} was not migrated`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Prints what to do next: install if needed, run it, and where the head layer goes.
|
|
169
|
+
function printNextSteps({ detected, log, tag = '' }) {
|
|
170
|
+
const run = runnerCommand(detected.runner, PACKAGE_NAME);
|
|
171
|
+
const install = installCommand(detected.runner, tag);
|
|
172
|
+
log.info('');
|
|
173
|
+
log.info('Next steps:');
|
|
174
|
+
if (!detected.installed) log.info(` Install ${install}`);
|
|
175
|
+
log.info(` Dry run ${run} --dry-run`);
|
|
176
|
+
log.info(` Prerender ${run}`);
|
|
177
|
+
log.info('');
|
|
178
|
+
log.info('Per-route titles and meta tags come from the head layer:');
|
|
179
|
+
log.info(" import { Head } from 'snappy-prerender/head';");
|
|
180
|
+
log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
|
|
181
|
+
if (detected.usesVite) {
|
|
182
|
+
log.info('');
|
|
183
|
+
log.info('Or run it inside the build (vite.config.js):');
|
|
184
|
+
log.info(" import snappy from 'snappy-prerender';");
|
|
185
|
+
log.info(' plugins: [react(), snappy()],');
|
|
186
|
+
log.info(
|
|
187
|
+
` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Builds the command that runs a locally installed binary for the project's package runner.
|
|
193
|
+
function runnerCommand(runner, binary) {
|
|
194
|
+
if (runner === 'pnpm') return `pnpm exec ${binary}`;
|
|
195
|
+
if (runner === 'yarn') return `yarn ${binary}`;
|
|
196
|
+
return `npx ${binary}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Builds the command that installs this package as a dev dependency.
|
|
200
|
+
function installCommand(runner, tag = '') {
|
|
201
|
+
const pkg = `${PACKAGE_NAME}${tag}`;
|
|
202
|
+
if (runner === 'pnpm') return `pnpm add -D ${pkg}`;
|
|
203
|
+
if (runner === 'yarn') return `yarn add -D ${pkg}`;
|
|
204
|
+
return `npm install --save-dev ${pkg}`;
|
|
205
|
+
}
|