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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Upper bound applied to the derived default concurrency. Browser pages are memory
|
|
6
|
+
* heavy, so the pool is capped even on machines with many cores; users can override
|
|
7
|
+
* with the `concurrency` option.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_DEFAULT_CONCURRENCY = 8;
|
|
10
|
+
|
|
11
|
+
const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'];
|
|
12
|
+
const BROWSER_CHOICES = ['auto', 'chrome', 'msedge', 'chromium'];
|
|
13
|
+
const INLINE_CSS_STRATEGIES = [false, true, 'inline', 'critical'];
|
|
14
|
+
const CSP_MODES = ['off', 'strict'];
|
|
15
|
+
const SAVE_AS_CHOICES = ['html', 'png', 'jpeg'];
|
|
16
|
+
const CRITICAL_CSS_PRELOAD = ['body', 'media', 'swap', 'swap-high', 'swap-low', 'js', 'js-lazy'];
|
|
17
|
+
const NULLABLE_KEYS = [
|
|
18
|
+
'storageState',
|
|
19
|
+
'readySelector',
|
|
20
|
+
'waitFor',
|
|
21
|
+
'maxDepth',
|
|
22
|
+
'notFoundRoute',
|
|
23
|
+
'maxRoutes',
|
|
24
|
+
'userAgent',
|
|
25
|
+
'destination',
|
|
26
|
+
'browserDownloadHash',
|
|
27
|
+
'url',
|
|
28
|
+
'serveCmd',
|
|
29
|
+
];
|
|
30
|
+
const ARRAY_KEYS = ['include', 'exclude', 'allowedHosts', 'browserArgs', 'ignoreForPreload'];
|
|
31
|
+
|
|
32
|
+
export const DEFAULTS = Object.freeze({
|
|
33
|
+
sourceDir: 'dist',
|
|
34
|
+
destination: null,
|
|
35
|
+
base: '/',
|
|
36
|
+
include: ['/'],
|
|
37
|
+
exclude: [],
|
|
38
|
+
crawl: true,
|
|
39
|
+
maxDepth: null,
|
|
40
|
+
maxRoutes: null,
|
|
41
|
+
concurrency: null,
|
|
42
|
+
timeout: 30000,
|
|
43
|
+
quietPeriod: 500,
|
|
44
|
+
scrollStepDelay: 100,
|
|
45
|
+
shutdownTimeout: 5000,
|
|
46
|
+
readyFlag: '__prerenderReady',
|
|
47
|
+
readySelector: null,
|
|
48
|
+
waitFor: null,
|
|
49
|
+
viewport: Object.freeze({ width: 1280, height: 720 }),
|
|
50
|
+
storageState: null,
|
|
51
|
+
userAgent: 'SnappyPrerender',
|
|
52
|
+
browser: 'auto',
|
|
53
|
+
browserDownload: true,
|
|
54
|
+
browserDownloadHash: null,
|
|
55
|
+
browserArgs: [],
|
|
56
|
+
headless: true,
|
|
57
|
+
ignoreHTTPSErrors: false,
|
|
58
|
+
blockThirdParty: false,
|
|
59
|
+
allowedHosts: [],
|
|
60
|
+
freezeAnimations: true,
|
|
61
|
+
scrollToBottom: false,
|
|
62
|
+
captureRuntimeStyles: true,
|
|
63
|
+
captureFormState: true,
|
|
64
|
+
saveAs: 'html',
|
|
65
|
+
csp: 'off',
|
|
66
|
+
inlineCss: false,
|
|
67
|
+
criticalCssPreload: 'media',
|
|
68
|
+
minifyHtml: false,
|
|
69
|
+
minifyCss: false,
|
|
70
|
+
removeBlobs: true,
|
|
71
|
+
removeStyleTags: false,
|
|
72
|
+
removeScriptTags: false,
|
|
73
|
+
asyncScriptTags: false,
|
|
74
|
+
externalScripts: false,
|
|
75
|
+
preconnectThirdParty: true,
|
|
76
|
+
preloadImages: false,
|
|
77
|
+
preloadManifest: false,
|
|
78
|
+
ignoreForPreload: ['service-worker.js'],
|
|
79
|
+
cacheAjaxRequests: false,
|
|
80
|
+
maxCachedBytes: 5 * 1024 * 1024,
|
|
81
|
+
flatOutput: false,
|
|
82
|
+
notFoundRoute: '/404',
|
|
83
|
+
verify: true,
|
|
84
|
+
failOnHydrationError: false,
|
|
85
|
+
failOnRerender: false,
|
|
86
|
+
failOnPageError: false,
|
|
87
|
+
metadata: null,
|
|
88
|
+
failOnError: true,
|
|
89
|
+
dryRun: false,
|
|
90
|
+
logLevel: 'info',
|
|
91
|
+
url: null,
|
|
92
|
+
serveCmd: null,
|
|
93
|
+
serveCmdTimeout: 30000,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Options whose default is boolean but which accept other shapes, so their own validators
|
|
97
|
+
// below decide what is allowed.
|
|
98
|
+
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
|
|
99
|
+
|
|
100
|
+
// Every boolean option is validated, derived from the defaults so a new flag cannot be
|
|
101
|
+
// forgotten in a hand-maintained list.
|
|
102
|
+
const BOOLEAN_KEYS = Object.entries(DEFAULTS)
|
|
103
|
+
.filter(([key, value]) => typeof value === 'boolean' && !MULTI_TYPE_KEYS.has(key))
|
|
104
|
+
.map(([key]) => key);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Merges user options over defaults and normalises the values the rest of the
|
|
108
|
+
* pipeline relies on, so downstream modules never repeat coercion logic.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveConfig(userOptions = {}) {
|
|
111
|
+
const config = { ...DEFAULTS, ...userOptions };
|
|
112
|
+
for (const key of NULLABLE_KEYS) config[key] = config[key] ?? null;
|
|
113
|
+
for (const key of ARRAY_KEYS) config[key] = toArray(config[key]);
|
|
114
|
+
config.viewport = { ...DEFAULTS.viewport, ...(userOptions.viewport ?? {}) };
|
|
115
|
+
config.base = normaliseBase(config.base);
|
|
116
|
+
config.concurrency =
|
|
117
|
+
userOptions.concurrency ??
|
|
118
|
+
Math.max(1, Math.min(os.availableParallelism(), MAX_DEFAULT_CONCURRENCY));
|
|
119
|
+
config.includeProvided = userOptions.include !== undefined;
|
|
120
|
+
// The CSP dial decides the defaults for the pieces that can move. Explicit options still
|
|
121
|
+
// win, so a project can take 'strict' and put one part back.
|
|
122
|
+
if (userOptions.externalScripts === undefined) config.externalScripts = config.csp === 'strict';
|
|
123
|
+
if (userOptions.criticalCssPreload === undefined) {
|
|
124
|
+
config.criticalCssPreload = config.csp === 'strict' ? false : 'media';
|
|
125
|
+
}
|
|
126
|
+
config.warnings = [];
|
|
127
|
+
validateConfig(config);
|
|
128
|
+
return config;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Ensures the base path always carries a leading and trailing slash so route and
|
|
133
|
+
* URL helpers can strip or append it without branching on user input shape.
|
|
134
|
+
*/
|
|
135
|
+
export function normaliseBase(base) {
|
|
136
|
+
if (typeof base !== 'string' || base.length === 0) {
|
|
137
|
+
throw new TypeError('base must be a non-empty string');
|
|
138
|
+
}
|
|
139
|
+
let value = base.startsWith('/') ? base : `/${base}`;
|
|
140
|
+
if (!value.endsWith('/')) value = `${value}/`;
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reports whether a browser option is a filesystem path rather than a channel
|
|
146
|
+
* choice. Shared by validation and browser resolution so both agree on the shape.
|
|
147
|
+
*/
|
|
148
|
+
export function isExecutablePath(value) {
|
|
149
|
+
return typeof value === 'string' && (value.includes('/') || value.includes('\\'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Normalises a single value or list option into an array, treating null as empty.
|
|
153
|
+
function toArray(value) {
|
|
154
|
+
if (value === null || value === undefined) return [];
|
|
155
|
+
return Array.isArray(value) ? value : [value];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fails fast on configuration mistakes before any browser or server work starts,
|
|
160
|
+
* so misconfiguration surfaces as a clear message instead of a mid-run timeout.
|
|
161
|
+
*/
|
|
162
|
+
export function validateConfig(config) {
|
|
163
|
+
// Validates that a pattern list only holds strings or RegExp instances.
|
|
164
|
+
const patternList = (list, name) => {
|
|
165
|
+
for (const entry of list) {
|
|
166
|
+
const valid = typeof entry === 'string' || entry instanceof RegExp;
|
|
167
|
+
if (!valid) throw new TypeError(`${name} entries must be strings or RegExp instances`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
// Validates that a plain list only holds strings.
|
|
171
|
+
const stringList = (list, name) => {
|
|
172
|
+
for (const entry of list) {
|
|
173
|
+
if (typeof entry !== 'string') throw new TypeError(`${name} entries must be strings`);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
// Validates that an optional value is either absent or a string.
|
|
177
|
+
const nullableString = (key) => {
|
|
178
|
+
if (config[key] !== null && typeof config[key] !== 'string') {
|
|
179
|
+
throw new TypeError(`${key} must be a string or null`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
// Validates that a flag option is a real boolean.
|
|
183
|
+
const booleanOption = (key) => {
|
|
184
|
+
if (typeof config[key] !== 'boolean') throw new TypeError(`${key} must be a boolean`);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
patternList(config.include, 'include');
|
|
188
|
+
patternList(config.exclude, 'exclude');
|
|
189
|
+
stringList(config.allowedHosts, 'allowedHosts');
|
|
190
|
+
stringList(config.browserArgs, 'browserArgs');
|
|
191
|
+
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
192
|
+
for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
|
|
193
|
+
for (const key of ['userAgent', 'destination']) nullableString(key);
|
|
194
|
+
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
195
|
+
|
|
196
|
+
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
197
|
+
throw new TypeError('browserDownloadHash must be a 64 character SHA-256 hex digest');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!Number.isInteger(config.concurrency) || config.concurrency < 1) {
|
|
201
|
+
throw new TypeError('concurrency must be a positive integer');
|
|
202
|
+
}
|
|
203
|
+
for (const key of [
|
|
204
|
+
'timeout',
|
|
205
|
+
'quietPeriod',
|
|
206
|
+
'scrollStepDelay',
|
|
207
|
+
'shutdownTimeout',
|
|
208
|
+
'serveCmdTimeout',
|
|
209
|
+
]) {
|
|
210
|
+
if (!Number.isFinite(config[key]) || config[key] <= 0) {
|
|
211
|
+
throw new TypeError(`${key} must be a positive number of milliseconds`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (config.maxDepth !== null && (!Number.isInteger(config.maxDepth) || config.maxDepth < 0)) {
|
|
215
|
+
throw new TypeError('maxDepth must be null or a non-negative integer');
|
|
216
|
+
}
|
|
217
|
+
if (config.maxRoutes !== null && (!Number.isInteger(config.maxRoutes) || config.maxRoutes < 1)) {
|
|
218
|
+
throw new TypeError('maxRoutes must be null or a positive integer');
|
|
219
|
+
}
|
|
220
|
+
if (!Number.isFinite(config.maxCachedBytes) || config.maxCachedBytes <= 0) {
|
|
221
|
+
throw new TypeError('maxCachedBytes must be a positive number of bytes');
|
|
222
|
+
}
|
|
223
|
+
for (const key of ['width', 'height']) {
|
|
224
|
+
if (!Number.isInteger(config.viewport[key]) || config.viewport[key] < 1) {
|
|
225
|
+
throw new TypeError(`viewport.${key} must be a positive integer`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!LOG_LEVELS.includes(config.logLevel)) {
|
|
229
|
+
throw new TypeError(`logLevel must be one of: ${LOG_LEVELS.join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
if (!BROWSER_CHOICES.includes(config.browser) && !isExecutablePath(config.browser)) {
|
|
232
|
+
throw new TypeError(
|
|
233
|
+
`browser must be one of: ${BROWSER_CHOICES.join(', ')} or a path to an executable`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (config.storageState !== null) {
|
|
237
|
+
if (typeof config.storageState !== 'string' || !fs.existsSync(config.storageState)) {
|
|
238
|
+
throw new TypeError('storageState must point to an existing Playwright storage state file');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (config.serveCmd !== null && !config.url) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
'url is required when serveCmd is set, so the crawler knows where to connect',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (config.url !== null && !/^https?:\/\//.test(config.url)) {
|
|
247
|
+
throw new TypeError('url must be an absolute http(s) URL');
|
|
248
|
+
}
|
|
249
|
+
if (!INLINE_CSS_STRATEGIES.includes(config.inlineCss)) {
|
|
250
|
+
throw new TypeError("inlineCss must be false, true, 'inline' or 'critical'");
|
|
251
|
+
}
|
|
252
|
+
if (!CSP_MODES.includes(config.csp)) {
|
|
253
|
+
throw new TypeError(`csp must be one of: ${CSP_MODES.join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
config.criticalCssPreload !== false &&
|
|
257
|
+
!CRITICAL_CSS_PRELOAD.includes(config.criticalCssPreload)
|
|
258
|
+
) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
`criticalCssPreload must be false or one of: ${CRITICAL_CSS_PRELOAD.join(', ')}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
for (const key of ['minifyHtml', 'minifyCss']) {
|
|
264
|
+
const value = config[key];
|
|
265
|
+
const valid =
|
|
266
|
+
typeof value === 'boolean' ||
|
|
267
|
+
(typeof value === 'object' && value !== null && !Array.isArray(value));
|
|
268
|
+
if (!valid) throw new TypeError(`${key} must be a boolean or an options object`);
|
|
269
|
+
}
|
|
270
|
+
if (!SAVE_AS_CHOICES.includes(config.saveAs)) {
|
|
271
|
+
throw new TypeError(`saveAs must be one of: ${SAVE_AS_CHOICES.join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
validateMetadata(config);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
277
|
+
const METADATA_KEYS = new Set([
|
|
278
|
+
'siteUrl',
|
|
279
|
+
'siteName',
|
|
280
|
+
'titleTemplate',
|
|
281
|
+
'defaultImage',
|
|
282
|
+
'trailingSlash',
|
|
283
|
+
'base',
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validates the defaults handed to the head component, so a typo surfaces at config time
|
|
288
|
+
* rather than as metadata that silently points at the build server.
|
|
289
|
+
*/
|
|
290
|
+
function validateMetadata(config) {
|
|
291
|
+
const metadata = config.metadata;
|
|
292
|
+
if (metadata === null) return;
|
|
293
|
+
if (typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
294
|
+
throw new TypeError('metadata must be null or an options object');
|
|
295
|
+
}
|
|
296
|
+
for (const key of Object.keys(metadata)) {
|
|
297
|
+
if (!METADATA_KEYS.has(key)) throw new TypeError(`metadata.${key} is not a recognised option`);
|
|
298
|
+
}
|
|
299
|
+
for (const key of METADATA_KEYS) {
|
|
300
|
+
const value = metadata[key];
|
|
301
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
302
|
+
throw new TypeError(`metadata.${key} must be a string`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
306
|
+
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
307
|
+
}
|
|
308
|
+
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
309
|
+
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
310
|
+
}
|
|
311
|
+
if (metadata.trailingSlash && !['preserve', 'always', 'never'].includes(metadata.trailingSlash)) {
|
|
312
|
+
throw new TypeError("metadata.trailingSlash must be 'preserve', 'always' or 'never'");
|
|
313
|
+
}
|
|
314
|
+
if (metadata.titleTemplate && !metadata.titleTemplate.includes('%s')) {
|
|
315
|
+
config.warnings ??= [];
|
|
316
|
+
config.warnings.push(
|
|
317
|
+
'metadata.titleTemplate has no %s placeholder, so the title is left as the page set it',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prepares the directory the run writes into. When a destination is configured the
|
|
6
|
+
* built output is copied there first, so assets are present next to the generated
|
|
7
|
+
* HTML and the source directory stays untouched. An existing destination is reported
|
|
8
|
+
* before it is overwritten, and entries that cannot be copied are counted.
|
|
9
|
+
*/
|
|
10
|
+
export async function prepareDestination({ sourceDir, destination, log }) {
|
|
11
|
+
if (!destination || path.resolve(destination) === path.resolve(sourceDir)) {
|
|
12
|
+
return sourceDir;
|
|
13
|
+
}
|
|
14
|
+
const existing = await fs.readdir(destination).catch(() => []);
|
|
15
|
+
if (existing.length > 0) {
|
|
16
|
+
log?.warn(
|
|
17
|
+
`destination ${destination} is not empty (${existing.length} entries); existing files may be overwritten`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const skipped = await copyTree(sourceDir, destination);
|
|
21
|
+
if (skipped > 0) {
|
|
22
|
+
log?.warn(`skipped ${skipped} non-file entr(ies) while copying ${sourceDir} to ${destination}`);
|
|
23
|
+
}
|
|
24
|
+
log?.debug(`copied ${sourceDir} to ${destination}`);
|
|
25
|
+
return destination;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively copies a directory tree. Implemented directly rather than with
|
|
30
|
+
* fs.cp so behaviour does not depend on an experimental Node API. Returns how many
|
|
31
|
+
* entries were skipped because they are neither files nor directories, such as symlinks.
|
|
32
|
+
*/
|
|
33
|
+
async function copyTree(source, target) {
|
|
34
|
+
await fs.mkdir(target, { recursive: true });
|
|
35
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
36
|
+
let skipped = 0;
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const from = path.join(source, entry.name);
|
|
39
|
+
const to = path.join(target, entry.name);
|
|
40
|
+
if (entry.isDirectory()) skipped += await copyTree(from, to);
|
|
41
|
+
else if (entry.isFile()) await fs.copyFile(from, to);
|
|
42
|
+
else skipped += 1;
|
|
43
|
+
}
|
|
44
|
+
return skipped;
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syncs live form control state into markup attributes. React writes checked and
|
|
3
|
+
* selected as DOM properties, which do not survive serialisation, so a prerendered
|
|
4
|
+
* checkbox or select would come back in its default state until the bundle hydrates.
|
|
5
|
+
* The result matches what React's own server rendering emits for controlled inputs.
|
|
6
|
+
*/
|
|
7
|
+
export async function captureFormState(page) {
|
|
8
|
+
await page.evaluate(() => {
|
|
9
|
+
// Writes or clears an attribute so the serialised markup matches the live DOM.
|
|
10
|
+
const syncAttribute = (element, attribute, enabled) => {
|
|
11
|
+
if (enabled) element.setAttribute(attribute, '');
|
|
12
|
+
else element.removeAttribute(attribute);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const choices = document.querySelectorAll('input[type="radio" i], input[type="checkbox" i]');
|
|
16
|
+
for (const control of choices) {
|
|
17
|
+
syncAttribute(control, 'checked', control.checked);
|
|
18
|
+
}
|
|
19
|
+
for (const option of document.querySelectorAll('option')) {
|
|
20
|
+
syncAttribute(option, 'selected', option.selected);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/core/init.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normaliseRoute } from './routes.js';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = 'snappy.config.js';
|
|
6
|
+
export const PACKAGE_NAME = 'snappy-prerender';
|
|
7
|
+
|
|
8
|
+
const VITE_CONFIGS = ['vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.ts'];
|
|
9
|
+
const METADATA_KEYS = ['siteUrl', 'siteName', 'titleTemplate', 'defaultImage', 'trailingSlash'];
|
|
10
|
+
const RUNNERS = [
|
|
11
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
12
|
+
['yarn.lock', 'yarn'],
|
|
13
|
+
['package-lock.json', 'npm'],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads what the project already says about itself: the framework that decides the build
|
|
18
|
+
* directory, whether Vite is in use, which package runner it uses, and whether this package
|
|
19
|
+
* is already a dependency.
|
|
20
|
+
*/
|
|
21
|
+
export async function detectProjectDefaults(dir = process.cwd()) {
|
|
22
|
+
const manifest = await readJson(path.join(dir, 'package.json'));
|
|
23
|
+
const dependencies = { ...manifest?.dependencies, ...manifest?.devDependencies };
|
|
24
|
+
const usesVite = Boolean(dependencies.vite) || (await anyExists(dir, VITE_CONFIGS));
|
|
25
|
+
const usesCreateReactApp = Boolean(dependencies['react-scripts']) && !usesVite;
|
|
26
|
+
return {
|
|
27
|
+
sourceDir: usesCreateReactApp ? 'build' : 'dist',
|
|
28
|
+
usesVite,
|
|
29
|
+
runner: await detectRunner(dir),
|
|
30
|
+
installed: PACKAGE_NAME in dependencies,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalises a comma separated route list into include patterns, keeping wildcards as the
|
|
36
|
+
* user wrote them and falling back to the root route so the config always renders something.
|
|
37
|
+
*/
|
|
38
|
+
export function parseRouteList(input) {
|
|
39
|
+
const routes = String(input ?? '')
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((route) => route.trim())
|
|
42
|
+
.filter((route) => route.length > 0)
|
|
43
|
+
.map((route) => (route.includes('*') ? route : normaliseRoute(route)));
|
|
44
|
+
return routes.length > 0 ? routes : ['/'];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders the config file for the wizard's answers, writing only the options the answers
|
|
49
|
+
* actually set so every line in the file does something.
|
|
50
|
+
*/
|
|
51
|
+
export function renderConfigFile(answers = {}) {
|
|
52
|
+
const lines = [
|
|
53
|
+
'// Created by `snappy-prerender init`. Every option is documented in the snappy-prerender README.',
|
|
54
|
+
'export default {',
|
|
55
|
+
` sourceDir: ${quote(answers.sourceDir ?? 'dist')},`,
|
|
56
|
+
];
|
|
57
|
+
if (answers.base && answers.base !== '/') lines.push(` base: ${quote(answers.base)},`);
|
|
58
|
+
// A supplied include list acts as an allowlist, so writing the default would stop discovery.
|
|
59
|
+
// It is only written when the answer actually narrows the set of routes.
|
|
60
|
+
const include = parseRouteList((answers.include ?? ['/']).join(','));
|
|
61
|
+
const includeIsDefault = include.length === 1 && include[0] === '/';
|
|
62
|
+
if (!includeIsDefault) lines.push(` include: [${include.map(quote).join(', ')}],`);
|
|
63
|
+
if (answers.crawl === false) lines.push(' crawl: false,');
|
|
64
|
+
// The CSP dial and the parts it was tuned with, so a strict-CSP project keeps the choice.
|
|
65
|
+
if (answers.csp === 'strict') lines.push(' csp: "strict",');
|
|
66
|
+
if (answers.externalScripts === true) lines.push(' externalScripts: true,');
|
|
67
|
+
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
68
|
+
// A boolean stays a literal; a mode name is quoted.
|
|
69
|
+
const value =
|
|
70
|
+
typeof answers.criticalCssPreload === 'boolean'
|
|
71
|
+
? String(answers.criticalCssPreload)
|
|
72
|
+
: quote(answers.criticalCssPreload);
|
|
73
|
+
lines.push(` criticalCssPreload: ${value},`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
77
|
+
if (metadata.length > 0) {
|
|
78
|
+
lines.push(' metadata: {');
|
|
79
|
+
for (const [key, value] of metadata) lines.push(` ${key}: ${quote(value)},`);
|
|
80
|
+
lines.push(' },');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push('};', '');
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Writes the config file into the project, refusing to replace an existing one unless the
|
|
89
|
+
* caller asked for it, so a wizard run can never silently destroy hand-written settings.
|
|
90
|
+
*/
|
|
91
|
+
export async function writeConfigFile({ dir, contents, force = false }) {
|
|
92
|
+
const target = path.join(dir, CONFIG_FILE);
|
|
93
|
+
if (!force && (await exists(target))) {
|
|
94
|
+
throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`);
|
|
95
|
+
}
|
|
96
|
+
await fs.writeFile(target, contents, 'utf8');
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Quotes a string for the generated module.
|
|
101
|
+
function quote(value) {
|
|
102
|
+
return JSON.stringify(String(value));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Reads a JSON file, treating a missing or malformed file as absent.
|
|
106
|
+
async function readJson(file) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Reports whether any of the given file names exists in the directory.
|
|
115
|
+
async function anyExists(dir, names) {
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
if (await exists(path.join(dir, name))) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Reports whether a path exists.
|
|
123
|
+
async function exists(target) {
|
|
124
|
+
return fs
|
|
125
|
+
.stat(target)
|
|
126
|
+
.then(() => true)
|
|
127
|
+
.catch(() => false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Detects the package runner from the lockfile, defaulting to npm.
|
|
131
|
+
async function detectRunner(dir) {
|
|
132
|
+
for (const [lockfile, runner] of RUNNERS) {
|
|
133
|
+
if (await exists(path.join(dir, lockfile))) return runner;
|
|
134
|
+
}
|
|
135
|
+
return 'npm';
|
|
136
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { minifyCss } from './minify.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inlines every same-origin stylesheet the page loaded, replacing the links with a
|
|
5
|
+
* single style tag. Blob-backed stylesheets work too because the text is read from
|
|
6
|
+
* the live page. Stylesheets that cannot be fetched are left in place.
|
|
7
|
+
*/
|
|
8
|
+
export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
9
|
+
const collected = await page.evaluate(async () => {
|
|
10
|
+
// Only media that applies on screen can be inlined without changing how the sheet
|
|
11
|
+
// applies, so a print stylesheet is left as a link instead of becoming active.
|
|
12
|
+
const INLINEABLE_MEDIA = new Set(['', 'all', 'screen']);
|
|
13
|
+
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
|
14
|
+
const parts = [];
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const link of links) {
|
|
17
|
+
const media = (link.getAttribute('media') ?? '').trim().toLowerCase();
|
|
18
|
+
if (!INLINEABLE_MEDIA.has(media)) {
|
|
19
|
+
skipped.push(link.href);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(link.href);
|
|
24
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
25
|
+
parts.push(await response.text());
|
|
26
|
+
link.remove();
|
|
27
|
+
} catch {
|
|
28
|
+
skipped.push(link.href);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { css: parts.join('\n'), skipped };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (collected.css.trim().length === 0) return { skipped: collected.skipped };
|
|
35
|
+
const css = minifyCssOptions ? minifyCss(collected.css, minifyCssOptions) : collected.css;
|
|
36
|
+
await page.evaluate((text) => {
|
|
37
|
+
const style = document.createElement('style');
|
|
38
|
+
style.textContent = text;
|
|
39
|
+
document.head.appendChild(style);
|
|
40
|
+
}, css);
|
|
41
|
+
return { skipped: collected.skipped };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Inlines only the above-the-fold CSS and defers the rest, using beasties. It runs
|
|
46
|
+
* on the serialised HTML in Node, reading stylesheets from the output directory.
|
|
47
|
+
* beasties is an optional peer dependency because most projects never need it.
|
|
48
|
+
* `preload` is passed through so a strict script-src can turn the swap handlers off.
|
|
49
|
+
*/
|
|
50
|
+
export async function inlineCriticalCss({ html, outputDir, base, preload = 'media' }) {
|
|
51
|
+
let Beasties;
|
|
52
|
+
try {
|
|
53
|
+
({ default: Beasties } = await import('beasties'));
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"inlineCss: 'critical' needs the optional beasties dependency. Install it with: npm install beasties",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const beasties = new Beasties({
|
|
60
|
+
path: outputDir,
|
|
61
|
+
publicPath: base,
|
|
62
|
+
logLevel: 'silent',
|
|
63
|
+
preload,
|
|
64
|
+
noscriptFallback: true,
|
|
65
|
+
});
|
|
66
|
+
return beasties.process(html);
|
|
67
|
+
}
|
package/src/core/log.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
|
|
3
|
+
const LEVEL_RANK = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Colour helper that disables ANSI output when the target stream is not a TTY or
|
|
7
|
+
* when NO_COLOR is set. FORCE_COLOR=1 forces colour, FORCE_COLOR=0 disables it.
|
|
8
|
+
*/
|
|
9
|
+
function paint(stream, format, text) {
|
|
10
|
+
const forced = process.env.FORCE_COLOR;
|
|
11
|
+
if (forced === '0') return text;
|
|
12
|
+
const enabled = forced ? true : Boolean(stream.isTTY) && !process.env.NO_COLOR;
|
|
13
|
+
if (!enabled) return text;
|
|
14
|
+
return styleText(format, text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Progress logger for long prerender runs. Writes to stdout/stderr only; the tool
|
|
19
|
+
* intentionally keeps no log files because it is a build step, not a service.
|
|
20
|
+
*/
|
|
21
|
+
export function createLogger(level = 'info') {
|
|
22
|
+
const rank = LEVEL_RANK[level] ?? LEVEL_RANK.info;
|
|
23
|
+
// Emits one coloured line to the requested stream.
|
|
24
|
+
const write = (target, format, message) => {
|
|
25
|
+
target.write(`${paint(target, format, message)}\n`);
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
level,
|
|
29
|
+
debug(message) {
|
|
30
|
+
if (rank >= LEVEL_RANK.debug) write(process.stdout, 'dim', message);
|
|
31
|
+
},
|
|
32
|
+
info(message) {
|
|
33
|
+
if (rank >= LEVEL_RANK.info) write(process.stdout, 'cyan', message);
|
|
34
|
+
},
|
|
35
|
+
warn(message) {
|
|
36
|
+
if (rank >= LEVEL_RANK.warn) write(process.stderr, 'yellow', message);
|
|
37
|
+
},
|
|
38
|
+
error(message) {
|
|
39
|
+
if (rank >= LEVEL_RANK.error) write(process.stderr, 'red', message);
|
|
40
|
+
},
|
|
41
|
+
success(message) {
|
|
42
|
+
if (rank >= LEVEL_RANK.info) write(process.stdout, 'green', message);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BUILD_ORIGIN = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?/gi;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recognises the metadata tags whose value is an absolute URL, and finds URLs pointing at a
|
|
5
|
+
* local build server inside them. Dependency-free on purpose, so the browser-side head entry
|
|
6
|
+
* can share the same pattern instead of shipping a second copy of it.
|
|
7
|
+
*/
|
|
8
|
+
export function isMetadataUrlNode(node) {
|
|
9
|
+
if (node.tagName === 'link') return attributeValue(node, 'rel').toLowerCase() === 'canonical';
|
|
10
|
+
if (node.tagName !== 'meta') return false;
|
|
11
|
+
return isUrlKey(attributeValue(node, 'property') || attributeValue(node, 'name'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Reads the URL a metadata tag carries.
|
|
15
|
+
export function metadataUrlValue(node) {
|
|
16
|
+
return node.tagName === 'link' ? attributeValue(node, 'href') : attributeValue(node, 'content');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collects the build server origins a value points at, de-duplicated and capped so one long
|
|
21
|
+
* value cannot flood a warning.
|
|
22
|
+
*/
|
|
23
|
+
export function collectBuildOrigins(text, limit = 3) {
|
|
24
|
+
const matches = String(text ?? '').match(BUILD_ORIGIN) ?? [];
|
|
25
|
+
return [...new Set(matches)].slice(0, limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reports whether a meta key names a URL, such as og:url, og:image or twitter:image.
|
|
29
|
+
function isUrlKey(key) {
|
|
30
|
+
const [prefix, ...rest] = key.toLowerCase().split(':');
|
|
31
|
+
if (prefix !== 'og' && prefix !== 'twitter') return false;
|
|
32
|
+
const last = rest.at(-1);
|
|
33
|
+
return last === 'url' || last === 'image';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Reads an attribute value, defaulting to an empty string.
|
|
37
|
+
function attributeValue(node, name) {
|
|
38
|
+
return node.attrs?.find((attribute) => attribute.name === name)?.value ?? '';
|
|
39
|
+
}
|