snappy-prerender 2.6.0 → 2.7.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 +51 -0
- package/README.md +122 -101
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +2 -1
- package/src/cli-init.js +205 -0
- package/src/cli.js +73 -168
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/config.js +129 -5
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +143 -30
- package/src/core/normalize.js +7 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/render.js +87 -5
- package/src/core/report.js +108 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/run.js +68 -113
- package/src/core/sitemap.js +90 -14
- package/src/core/verify.js +92 -48
- package/src/core/write.js +92 -7
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +52 -6
|
@@ -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,102 @@
|
|
|
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
|
+
"ok": true,
|
|
13
|
+
"summary": { "routes": 0, "files": 0, "errors": 0, "warnings": 0 },
|
|
14
|
+
"verification": {
|
|
15
|
+
"ok": true,
|
|
16
|
+
"modes": { "hydrated": 0, "re-rendered": 0, "undetected": 0, "unknown": 0 },
|
|
17
|
+
"frameworks": { "react": 0 },
|
|
18
|
+
"skipped": 0,
|
|
19
|
+
"errors": []
|
|
20
|
+
},
|
|
21
|
+
"routes": [
|
|
22
|
+
{
|
|
23
|
+
"route": "/",
|
|
24
|
+
"order": 0,
|
|
25
|
+
"file": "index.html",
|
|
26
|
+
"status": "written",
|
|
27
|
+
"bytes": 0,
|
|
28
|
+
"depth": 0,
|
|
29
|
+
"attempts": 1,
|
|
30
|
+
"httpStatus": 200,
|
|
31
|
+
"finalUrl": "http://127.0.0.1:1234/",
|
|
32
|
+
"mode": "hydrated",
|
|
33
|
+
"framework": "react",
|
|
34
|
+
"empty": false,
|
|
35
|
+
"notFound": false,
|
|
36
|
+
"noindex": false,
|
|
37
|
+
"meta": {
|
|
38
|
+
"title": "",
|
|
39
|
+
"description": "",
|
|
40
|
+
"canonical": "",
|
|
41
|
+
"robots": "",
|
|
42
|
+
"h1Count": 1,
|
|
43
|
+
"alternates": [],
|
|
44
|
+
"images": [],
|
|
45
|
+
"inlineStyleAttributes": 0
|
|
46
|
+
},
|
|
47
|
+
"hydrationErrors": [],
|
|
48
|
+
"pageErrors": [],
|
|
49
|
+
"consoleErrors": [],
|
|
50
|
+
"failedRequests": [],
|
|
51
|
+
"firstMismatch": null
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"audit": { "errors": [], "warnings": [], "linksChecked": 0 },
|
|
55
|
+
"sitemap": null,
|
|
56
|
+
"preloadManifest": null,
|
|
57
|
+
"truncated": null,
|
|
58
|
+
"notFoundRoutes": [],
|
|
59
|
+
"duplicateOutputs": [],
|
|
60
|
+
"routeSources": [],
|
|
61
|
+
"cspSkipped": [],
|
|
62
|
+
"traces": []
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The document never includes configuration, `storageState`, captured AJAX state, cookies,
|
|
67
|
+
tokens, request bodies or full asset URLs containing credentials. URLs in the JSON are
|
|
68
|
+
reduced to their origin and path, and the configured `storageState` path is redacted
|
|
69
|
+
wherever it appears in a message. `order` is the crawl acceptance order; with
|
|
70
|
+
`maxRoutes` or `routePriority` it follows the deterministic wave order rather than the
|
|
71
|
+
alphabetical write order.
|
|
72
|
+
|
|
73
|
+
## Audit rules
|
|
74
|
+
|
|
75
|
+
The audit runs by default and reports findings without failing the build. Set
|
|
76
|
+
`failOnAuditError: true` (CLI `--fail-on-audit-error`) to make `ok` false when an error is
|
|
77
|
+
present. `auditIgnoreLinks` (CLI `--audit-ignore-links`) excludes known dynamic links.
|
|
78
|
+
|
|
79
|
+
Errors:
|
|
80
|
+
|
|
81
|
+
- `route-error` — a route failed while rendering, or its output could not be written.
|
|
82
|
+
- `route-cap` — `maxRoutes` stopped the crawl before every discovered route was accepted.
|
|
83
|
+
- `empty-page` — a route rendered no app content.
|
|
84
|
+
- `duplicate-output` — two routes produced byte-identical HTML.
|
|
85
|
+
- `broken-internal-link` — an internal link with no written route or output file.
|
|
86
|
+
- `duplicate-title` — two routes share a title.
|
|
87
|
+
- `multiple-titles` — a page ships more than one document `<title>`.
|
|
88
|
+
- `sitemap-missing-page` — an indexable page is absent from the sitemap.
|
|
89
|
+
- `sitemap-unknown-page` — the sitemap lists a URL with no indexable page.
|
|
90
|
+
- `sitemap-noindex-page` — the sitemap lists a noindex or 404 page.
|
|
91
|
+
|
|
92
|
+
Warnings:
|
|
93
|
+
|
|
94
|
+
- `missing-title`, `missing-description`, `missing-canonical`.
|
|
95
|
+
- `duplicate-description`.
|
|
96
|
+
- `heading-count` — zero or more than one `<h1>`.
|
|
97
|
+
- `canonical-mismatch` — canonical differs from the expected public URL.
|
|
98
|
+
- `build-origin-metadata` — a tag still points at the build server.
|
|
99
|
+
- `orphan-page` — an indexable page no written page links to.
|
|
100
|
+
- `inline-style-attributes` — `csp: 'hash'` cannot cover `style=""` attributes.
|
|
101
|
+
- `existing-csp-meta` — a CSP meta already existed, so hashes were not injected.
|
|
102
|
+
- `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.7.0",
|
|
4
4
|
"description": "Drop-in prerendering for existing single-page apps. Renders your built app in a real browser, writes static HTML per route, reports React hydration mismatches, and optimises the output with critical CSS, link hints and cached AJAX state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
43
|
+
"docs",
|
|
43
44
|
"CHANGELOG.md",
|
|
44
45
|
"LICENSE",
|
|
45
46
|
"README.md"
|
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
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import process from 'node:process';
|
|
4
|
-
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import {
|
|
8
|
-
CONFIG_FILE,
|
|
9
|
-
detectProjectDefaults,
|
|
10
|
-
PACKAGE_NAME,
|
|
11
|
-
parseRouteList,
|
|
12
|
-
renderConfigFile,
|
|
13
|
-
writeConfigFile,
|
|
14
|
-
} from './core/init.js';
|
|
7
|
+
import { runInit } from './cli-init.js';
|
|
15
8
|
import { createLogger } from './core/log.js';
|
|
16
9
|
import { prerender } from './core/run.js';
|
|
17
10
|
|
|
@@ -66,6 +59,20 @@ Options:
|
|
|
66
59
|
--scroll Scroll through each page to trigger lazy content
|
|
67
60
|
--flat Write about.html instead of about/index.html
|
|
68
61
|
--not-found <route> Route emitted as 404.html (default: /404)
|
|
62
|
+
--routes-file <file> Read route seeds from a text/JSON file
|
|
63
|
+
--routes-from-sitemap <path-or-url> Seed routes from a local or remote sitemap
|
|
64
|
+
--route-priority <patterns> Patterns ranked first when crawling, comma separated
|
|
65
|
+
--report <file> Write a machine-readable run report (JSON)
|
|
66
|
+
--audit Check the written site for broken links and SEO issues (default: on)
|
|
67
|
+
--no-audit Skip the site audit
|
|
68
|
+
--fail-on-audit-error Fail when the audit finds an error
|
|
69
|
+
--audit-ignore-links <patterns> Link patterns excluded from the audit, comma separated
|
|
70
|
+
--preload-links Inject module, font and LCP image preload hints into each page
|
|
71
|
+
--image-sitemap List page images in the sitemap
|
|
72
|
+
--framework <name> auto | react | vue | svelte | preact (default: auto)
|
|
73
|
+
--retries <n> Retry a failed render this many times (default: 0)
|
|
74
|
+
--retry-delay <ms> Wait between render attempts (default: 500)
|
|
75
|
+
--trace-on-error Write a Playwright trace for the final failed attempt
|
|
69
76
|
--destination <dir> Write output to another directory (default: in place)
|
|
70
77
|
--clean-destination Empty the destination before copying the build output
|
|
71
78
|
--save-as <format> html | png | jpeg (default: html)
|
|
@@ -92,7 +99,7 @@ Options:
|
|
|
92
99
|
--remove-styles Strip every style tag from the output
|
|
93
100
|
--async-scripts Mark external scripts async
|
|
94
101
|
--external-scripts Write injected state scripts as same-origin files, for a strict CSP
|
|
95
|
-
--csp <mode> off | strict: strict
|
|
102
|
+
--csp <mode> off | strict | hash: strict for script-src 'self', hash for inline hashes
|
|
96
103
|
--no-remove-blobs Keep blob stylesheet links instead of dropping them
|
|
97
104
|
--remove-attributes <names> Drop these attributes from the output, comma separated
|
|
98
105
|
--no-strip-build-origin-metadata Keep tags that point at the prerender server
|
|
@@ -165,6 +172,20 @@ async function main() {
|
|
|
165
172
|
scroll: { type: 'boolean' },
|
|
166
173
|
flat: { type: 'boolean' },
|
|
167
174
|
'not-found': { type: 'string' },
|
|
175
|
+
'routes-file': { type: 'string' },
|
|
176
|
+
'routes-from-sitemap': { type: 'string' },
|
|
177
|
+
'route-priority': { type: 'string', multiple: true },
|
|
178
|
+
report: { type: 'string' },
|
|
179
|
+
audit: { type: 'boolean' },
|
|
180
|
+
'no-audit': { type: 'boolean' },
|
|
181
|
+
'fail-on-audit-error': { type: 'boolean' },
|
|
182
|
+
'audit-ignore-links': { type: 'string', multiple: true },
|
|
183
|
+
'preload-links': { type: 'boolean' },
|
|
184
|
+
'image-sitemap': { type: 'boolean' },
|
|
185
|
+
framework: { type: 'string' },
|
|
186
|
+
retries: { type: 'string' },
|
|
187
|
+
'retry-delay': { type: 'string' },
|
|
188
|
+
'trace-on-error': { type: 'boolean' },
|
|
168
189
|
destination: { type: 'string' },
|
|
169
190
|
'clean-destination': { type: 'boolean' },
|
|
170
191
|
'save-as': { type: 'string' },
|
|
@@ -219,7 +240,12 @@ async function main() {
|
|
|
219
240
|
return;
|
|
220
241
|
}
|
|
221
242
|
if (parsed.positionals[0] === 'init') {
|
|
222
|
-
await runInit(
|
|
243
|
+
await runInit({
|
|
244
|
+
values: parsed.values,
|
|
245
|
+
flags: cliOptions({ values: parsed.values, positionals: [] }),
|
|
246
|
+
log: createLogger(parsed.values['log-level'] ?? 'info'),
|
|
247
|
+
version: readVersion(),
|
|
248
|
+
});
|
|
223
249
|
return;
|
|
224
250
|
}
|
|
225
251
|
|
|
@@ -307,6 +333,26 @@ function cliOptions({ values, positionals }) {
|
|
|
307
333
|
if (values.scroll) options.scrollToBottom = true;
|
|
308
334
|
if (values.flat) options.flatOutput = true;
|
|
309
335
|
if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
|
|
336
|
+
if (values['routes-file'] !== undefined) options.routesFile = values['routes-file'];
|
|
337
|
+
if (values['routes-from-sitemap'] !== undefined) {
|
|
338
|
+
options.routesFromSitemap = values['routes-from-sitemap'];
|
|
339
|
+
}
|
|
340
|
+
if (values['route-priority'] !== undefined) {
|
|
341
|
+
options.routePriority = splitList(values['route-priority']);
|
|
342
|
+
}
|
|
343
|
+
if (values.report !== undefined) options.reportPath = values.report;
|
|
344
|
+
if (values.audit !== undefined) options.audit = values.audit;
|
|
345
|
+
else if (values['no-audit']) options.audit = false;
|
|
346
|
+
if (values['fail-on-audit-error']) options.failOnAuditError = true;
|
|
347
|
+
if (values['audit-ignore-links'] !== undefined) {
|
|
348
|
+
options.auditIgnoreLinks = splitList(values['audit-ignore-links']);
|
|
349
|
+
}
|
|
350
|
+
if (values['preload-links']) options.preloadLinks = true;
|
|
351
|
+
if (values['image-sitemap']) options.imageSitemap = true;
|
|
352
|
+
if (values.framework !== undefined) options.framework = values.framework;
|
|
353
|
+
if (values.retries !== undefined) options.retries = Number(values.retries);
|
|
354
|
+
if (values['retry-delay'] !== undefined) options.retryDelay = Number(values['retry-delay']);
|
|
355
|
+
if (values['trace-on-error']) options.traceOnError = true;
|
|
310
356
|
if (values.destination !== undefined) options.destination = values.destination;
|
|
311
357
|
if (values['clean-destination']) options.cleanDestination = true;
|
|
312
358
|
if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
|
|
@@ -354,162 +400,6 @@ function splitList(entries) {
|
|
|
354
400
|
return entries.flatMap((entry) => entry.split(',')).filter((entry) => entry.length > 0);
|
|
355
401
|
}
|
|
356
402
|
|
|
357
|
-
/**
|
|
358
|
-
* Setup wizard: asks for the handful of values that decide what gets prerendered and what the
|
|
359
|
-
* metadata says, then writes snappy.config.js. Flags cover every question, so
|
|
360
|
-
* `snappy-prerender init --yes --metadata-site-url https://example.com` works without a
|
|
361
|
-
* terminal.
|
|
362
|
-
*/
|
|
363
|
-
async function runInit({ values }) {
|
|
364
|
-
const log = createLogger(values['log-level'] ?? 'info');
|
|
365
|
-
const cwd = process.cwd();
|
|
366
|
-
if (!values.force && fs.existsSync(CONFIG_FILE)) {
|
|
367
|
-
throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
|
|
368
|
-
}
|
|
369
|
-
if (!values.yes && !process.stdin.isTTY) {
|
|
370
|
-
throw new Error('init needs an interactive terminal; pass --yes to accept the defaults');
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const detected = await detectProjectDefaults(cwd);
|
|
374
|
-
const flags = cliOptions({ values, positionals: [] });
|
|
375
|
-
const defaults = defaultsFromFlags(detected, flags);
|
|
376
|
-
const answers = values.yes ? defaults : await askAnswers(defaults, log);
|
|
377
|
-
const cleaned = validatedAnswers(answers, log);
|
|
378
|
-
|
|
379
|
-
await writeConfigFile({
|
|
380
|
-
dir: cwd,
|
|
381
|
-
contents: renderConfigFile(cleaned),
|
|
382
|
-
force: values.force === true,
|
|
383
|
-
});
|
|
384
|
-
log.success(`Wrote ${CONFIG_FILE}`);
|
|
385
|
-
printNextSteps({ detected, log, tag: readVersion().includes('-') ? '@next' : '' });
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Turns the flags the CLI already understands into the wizard's starting answers.
|
|
389
|
-
function defaultsFromFlags(detected, flags) {
|
|
390
|
-
return {
|
|
391
|
-
sourceDir: flags.sourceDir ?? detected.sourceDir,
|
|
392
|
-
base: flags.base ?? detected.base ?? '/',
|
|
393
|
-
include: flags.include ?? ['/'],
|
|
394
|
-
crawl: flags.crawl ?? true,
|
|
395
|
-
siteUrl: flags.metadata?.siteUrl ?? '',
|
|
396
|
-
siteName: flags.metadata?.siteName ?? '',
|
|
397
|
-
titleTemplate: flags.metadata?.titleTemplate ?? '',
|
|
398
|
-
defaultImage: flags.metadata?.defaultImage ?? '',
|
|
399
|
-
trailingSlash: flags.metadata?.trailingSlash ?? '',
|
|
400
|
-
csp: flags.csp ?? 'off',
|
|
401
|
-
externalScripts: flags.externalScripts,
|
|
402
|
-
criticalCssPreload: flags.criticalCssPreload,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Asks every question in order, closing the terminal interface whatever the outcome.
|
|
407
|
-
async function askAnswers(defaults, log) {
|
|
408
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
409
|
-
try {
|
|
410
|
-
log.info('A few questions, then a config file with only the options you set.');
|
|
411
|
-
return {
|
|
412
|
-
sourceDir: await ask(rl, 'Build output directory', defaults.sourceDir),
|
|
413
|
-
base: await ask(rl, 'Public base path', defaults.base),
|
|
414
|
-
include: parseRouteList(
|
|
415
|
-
await ask(rl, 'Routes to prerender, comma separated', defaults.include.join(',')),
|
|
416
|
-
),
|
|
417
|
-
crawl: await askYesNo(rl, 'Follow same-origin links while rendering?', defaults.crawl),
|
|
418
|
-
siteUrl: await ask(
|
|
419
|
-
rl,
|
|
420
|
-
'Public site URL for canonical and og:url, blank to skip',
|
|
421
|
-
defaults.siteUrl,
|
|
422
|
-
),
|
|
423
|
-
siteName: await ask(rl, 'Site name, blank to skip', defaults.siteName),
|
|
424
|
-
titleTemplate: await ask(
|
|
425
|
-
rl,
|
|
426
|
-
"Title template such as '%s | Example', blank to skip",
|
|
427
|
-
defaults.titleTemplate,
|
|
428
|
-
),
|
|
429
|
-
csp: (await askYesNo(
|
|
430
|
-
rl,
|
|
431
|
-
"Serve a strict CSP (script-src 'self', no inline scripts)?",
|
|
432
|
-
defaults.csp === 'strict',
|
|
433
|
-
))
|
|
434
|
-
? 'strict'
|
|
435
|
-
: 'off',
|
|
436
|
-
defaultImage: defaults.defaultImage,
|
|
437
|
-
trailingSlash: defaults.trailingSlash,
|
|
438
|
-
};
|
|
439
|
-
} finally {
|
|
440
|
-
rl.close();
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Asks one question, showing the value Enter will accept.
|
|
445
|
-
async function ask(rl, question, fallback) {
|
|
446
|
-
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
447
|
-
return answer.length > 0 ? answer : fallback;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Asks a yes/no question, treating Enter as the default.
|
|
451
|
-
async function askYesNo(rl, question, fallback) {
|
|
452
|
-
const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}): `))
|
|
453
|
-
.trim()
|
|
454
|
-
.toLowerCase();
|
|
455
|
-
if (answer.length === 0) return fallback;
|
|
456
|
-
return answer === 'y' || answer === 'yes';
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Drops values that would make a broken config, so a typo becomes a warning instead.
|
|
460
|
-
function validatedAnswers(answers, log) {
|
|
461
|
-
const cleaned = { ...answers };
|
|
462
|
-
if (cleaned.siteUrl && !/^https?:\/\//.test(cleaned.siteUrl)) {
|
|
463
|
-
log.warn(`ignoring site URL ${cleaned.siteUrl}: it must start with http:// or https://`);
|
|
464
|
-
cleaned.siteUrl = '';
|
|
465
|
-
}
|
|
466
|
-
if (cleaned.titleTemplate && !cleaned.titleTemplate.includes('%s')) {
|
|
467
|
-
log.warn('ignoring the title template: it has no %s placeholder');
|
|
468
|
-
cleaned.titleTemplate = '';
|
|
469
|
-
}
|
|
470
|
-
cleaned.include = parseRouteList((cleaned.include ?? ['/']).join(','));
|
|
471
|
-
return cleaned;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Prints what to do next: install if needed, run it, and where the head layer goes.
|
|
475
|
-
function printNextSteps({ detected, log, tag = '' }) {
|
|
476
|
-
const run = runnerCommand(detected.runner, PACKAGE_NAME);
|
|
477
|
-
const install = installCommand(detected.runner, tag);
|
|
478
|
-
log.info('');
|
|
479
|
-
log.info('Next steps:');
|
|
480
|
-
if (!detected.installed) log.info(` Install ${install}`);
|
|
481
|
-
log.info(` Dry run ${run} --dry-run`);
|
|
482
|
-
log.info(` Prerender ${run}`);
|
|
483
|
-
log.info('');
|
|
484
|
-
log.info('Per-route titles and meta tags come from the head layer:');
|
|
485
|
-
log.info(" import { Head } from 'snappy-prerender/head';");
|
|
486
|
-
log.info(' <Head title="Awards" description="..." image="/share/awards.png" />');
|
|
487
|
-
if (detected.usesVite) {
|
|
488
|
-
log.info('');
|
|
489
|
-
log.info('Or run it inside the build (vite.config.js):');
|
|
490
|
-
log.info(" import snappy from 'snappy-prerender';");
|
|
491
|
-
log.info(' plugins: [react(), snappy()],');
|
|
492
|
-
log.info(
|
|
493
|
-
` ${CONFIG_FILE} is read by the snappy-prerender command, so pass the same options to snappy() too.`,
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Builds the command that runs a locally installed binary for the project's package runner.
|
|
499
|
-
function runnerCommand(runner, binary) {
|
|
500
|
-
if (runner === 'pnpm') return `pnpm exec ${binary}`;
|
|
501
|
-
if (runner === 'yarn') return `yarn ${binary}`;
|
|
502
|
-
return `npx ${binary}`;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Builds the command that installs this package as a dev dependency.
|
|
506
|
-
function installCommand(runner, tag = '') {
|
|
507
|
-
const pkg = `${PACKAGE_NAME}${tag}`;
|
|
508
|
-
if (runner === 'pnpm') return `pnpm add -D ${pkg}`;
|
|
509
|
-
if (runner === 'yarn') return `yarn add -D ${pkg}`;
|
|
510
|
-
return `npm install --save-dev ${pkg}`;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
403
|
// Collects the metadata flags, or null when none was passed so a config file value survives.
|
|
514
404
|
function metadataOptions(values) {
|
|
515
405
|
const metadata = {};
|
|
@@ -556,7 +446,22 @@ async function loadConfigFile(explicitPath) {
|
|
|
556
446
|
`Config file must export a plain options object or a function returning one: ${explicitPath}`,
|
|
557
447
|
);
|
|
558
448
|
}
|
|
559
|
-
return loaded;
|
|
449
|
+
return resolveSourcePaths(loaded, path.dirname(path.resolve(explicitPath)));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Resolves local route-source paths against the config file, not the process cwd, while
|
|
453
|
+
// leaving URLs and absolute paths alone.
|
|
454
|
+
function resolveSourcePaths(options, dir) {
|
|
455
|
+
const resolve = (value) => {
|
|
456
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
457
|
+
if (/^https?:\/\//i.test(value) || path.isAbsolute(value)) return value;
|
|
458
|
+
return path.resolve(dir, value);
|
|
459
|
+
};
|
|
460
|
+
return {
|
|
461
|
+
...options,
|
|
462
|
+
routesFile: resolve(options.routesFile),
|
|
463
|
+
routesFromSitemap: resolve(options.routesFromSitemap),
|
|
464
|
+
};
|
|
560
465
|
}
|
|
561
466
|
|
|
562
467
|
// Reads the package version without importing JSON, keeping Node 20 compatible.
|
package/src/core/artifacts.js
CHANGED
|
@@ -69,6 +69,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
|
|
|
69
69
|
routes: sitemap.routes,
|
|
70
70
|
robotsUpdated: patched !== null,
|
|
71
71
|
files: names,
|
|
72
|
+
locs: sitemap.locs,
|
|
72
73
|
};
|
|
73
74
|
log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
|
|
74
75
|
return;
|
|
@@ -89,6 +90,7 @@ export async function writeSitemap({ outputDir, config, log, report }) {
|
|
|
89
90
|
routes: sitemap.routes,
|
|
90
91
|
robotsUpdated: patched !== null,
|
|
91
92
|
files: names,
|
|
93
|
+
locs: sitemap.locs,
|
|
92
94
|
};
|
|
93
95
|
log.info(
|
|
94
96
|
`${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
|