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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
|
|
3
|
+
const CONTAINER_IDS = new Set(['root', 'app', '__next']);
|
|
4
|
+
const STABLE_ATTRIBUTES = ['id', 'class', 'href', 'src', 'type', 'role', 'name'];
|
|
5
|
+
const MAX_NODES = 5000;
|
|
6
|
+
const MAX_DEPTH = 20;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Finds the first meaningful difference between the prerendered document and the live
|
|
10
|
+
* post-boot container. Bounded so a large app cannot turn verification into a memory
|
|
11
|
+
* problem.
|
|
12
|
+
*/
|
|
13
|
+
export function compareContainers(expectedHtml, liveHtml) {
|
|
14
|
+
const expected = containerOf(parse(expectedHtml));
|
|
15
|
+
const actual = containerOf(parse(liveHtml));
|
|
16
|
+
if (!expected || !actual) return null;
|
|
17
|
+
const state = { visited: 0 };
|
|
18
|
+
return diffNode(expected, actual, 'body', 0, state);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Finds the app container in a parsed document, falling back to the body's first child.
|
|
22
|
+
function containerOf(document) {
|
|
23
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
24
|
+
const body = (html?.childNodes ?? []).find((node) => node.tagName === 'body');
|
|
25
|
+
if (!body) return null;
|
|
26
|
+
const container = (body.childNodes ?? []).find(
|
|
27
|
+
(node) => node.tagName && CONTAINER_IDS.has(attribute(node, 'id')),
|
|
28
|
+
);
|
|
29
|
+
return container ?? (body.childNodes ?? []).find((node) => node.tagName) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Compares one node pair and descends into the first differing child.
|
|
33
|
+
function diffNode(expected, actual, path, depth, state) {
|
|
34
|
+
if (state.visited > MAX_NODES || depth > MAX_DEPTH) return null;
|
|
35
|
+
state.visited += 1;
|
|
36
|
+
const here = `${path}>${expected.tagName ?? expected.nodeName}`;
|
|
37
|
+
if (!actual || expected.tagName !== actual.tagName) {
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
kind: 'tag',
|
|
41
|
+
expected: expected.tagName ?? expected.nodeName,
|
|
42
|
+
actual: actual?.tagName ?? actual?.nodeName ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
for (const name of STABLE_ATTRIBUTES) {
|
|
46
|
+
const left = attribute(expected, name);
|
|
47
|
+
const right = attribute(actual, name);
|
|
48
|
+
if (left !== right)
|
|
49
|
+
return { path: here, kind: 'attribute', name, expected: left, actual: right };
|
|
50
|
+
}
|
|
51
|
+
const expectedChildren = elementChildren(expected);
|
|
52
|
+
const actualChildren = elementChildren(actual);
|
|
53
|
+
for (let index = 0; index < expectedChildren.length; index += 1) {
|
|
54
|
+
const child = expectedChildren[index];
|
|
55
|
+
const other = actualChildren[index];
|
|
56
|
+
if (!other) {
|
|
57
|
+
return {
|
|
58
|
+
path: here,
|
|
59
|
+
kind: 'missing',
|
|
60
|
+
expected: child.tagName ?? child.nodeName,
|
|
61
|
+
actual: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const difference = diffNode(child, other, `${here}[${index}]`, depth + 1, state);
|
|
65
|
+
if (difference) return difference;
|
|
66
|
+
}
|
|
67
|
+
if (actualChildren.length > expectedChildren.length) {
|
|
68
|
+
const extra = actualChildren[expectedChildren.length];
|
|
69
|
+
return { path: here, kind: 'extra', expected: null, actual: extra.tagName ?? extra.nodeName };
|
|
70
|
+
}
|
|
71
|
+
const expectedText = directText(expected);
|
|
72
|
+
const actualText = directText(actual);
|
|
73
|
+
if (expectedText !== actualText) {
|
|
74
|
+
return { path: here, kind: 'text', expected: expectedText, actual: actualText };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function elementChildren(node) {
|
|
80
|
+
return (node.childNodes ?? []).filter((child) => child.tagName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function directText(node) {
|
|
84
|
+
return (node.childNodes ?? [])
|
|
85
|
+
.filter((child) => child.nodeName === '#text')
|
|
86
|
+
.map((child) => child.value)
|
|
87
|
+
.join('')
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function attribute(node, name) {
|
|
92
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { matchesPattern } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the hydration policy for one route. Global lists provide the default and a
|
|
5
|
+
* matching route override wins, so a broad allow-list can be narrowed for one route.
|
|
6
|
+
* @param {object} config
|
|
7
|
+
* @param {string} route
|
|
8
|
+
* @returns {'strict' | 'allow-rerender' | 'skip'}
|
|
9
|
+
*/
|
|
10
|
+
export function resolveHydrationPolicy(config, route) {
|
|
11
|
+
let policy = 'strict';
|
|
12
|
+
if ((config.hydration?.skip ?? []).some((pattern) => matchesPattern(route, pattern))) {
|
|
13
|
+
policy = 'skip';
|
|
14
|
+
} else if (
|
|
15
|
+
(config.hydration?.allowRerender ?? []).some((pattern) => matchesPattern(route, pattern))
|
|
16
|
+
) {
|
|
17
|
+
policy = 'allow-rerender';
|
|
18
|
+
}
|
|
19
|
+
for (const override of config.routeOverrides ?? []) {
|
|
20
|
+
if (!matchesPattern(route, override.match)) continue;
|
|
21
|
+
if (override.hydration !== undefined) policy = override.hydration;
|
|
22
|
+
}
|
|
23
|
+
return policy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reports whether a verification result is allowed to differ from the prerendered markup.
|
|
28
|
+
* A route only becomes allowed after it actually re-rendered or logged a hydration error.
|
|
29
|
+
* @param {'strict' | 'allow-rerender' | 'skip'} policy
|
|
30
|
+
* @param {{ mode: string, hydrationErrors: string[] }} result
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isHydrationAllowed(policy, result) {
|
|
34
|
+
return (
|
|
35
|
+
policy === 'allow-rerender' &&
|
|
36
|
+
(result.mode === 're-rendered' || (result.hydrationErrors?.length ?? 0) > 0)
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/core/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { DEFAULTS } from './config.js';
|
|
5
6
|
import { normaliseRoute } from './routes.js';
|
|
6
7
|
|
|
7
8
|
export const CONFIG_FILE = 'snappy.config.js';
|
|
@@ -15,6 +16,47 @@ const RUNNERS = [
|
|
|
15
16
|
['package-lock.json', 'npm'],
|
|
16
17
|
];
|
|
17
18
|
|
|
19
|
+
// react-snap options whose name differs here, mapped to this package's option.
|
|
20
|
+
const REACT_SNAP_RENAMED = {
|
|
21
|
+
source: 'sourceDir',
|
|
22
|
+
puppeteerArgs: 'browserArgs',
|
|
23
|
+
puppeteerIgnoreHTTPSErrors: 'ignoreHTTPSErrors',
|
|
24
|
+
skipThirdPartyRequests: 'blockThirdParty',
|
|
25
|
+
fixInsertRule: 'captureRuntimeStyles',
|
|
26
|
+
fixFormFields: 'captureFormState',
|
|
27
|
+
http2PushManifest: 'preloadManifest',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// react-snap options that keep their name and meaning.
|
|
31
|
+
const REACT_SNAP_PASSTHROUGH = new Set([
|
|
32
|
+
'include',
|
|
33
|
+
'exclude',
|
|
34
|
+
'crawl',
|
|
35
|
+
'concurrency',
|
|
36
|
+
'timeout',
|
|
37
|
+
'waitFor',
|
|
38
|
+
'inlineCss',
|
|
39
|
+
'minifyHtml',
|
|
40
|
+
'minifyCss',
|
|
41
|
+
'cacheAjaxRequests',
|
|
42
|
+
'preconnectThirdParty',
|
|
43
|
+
'preloadImages',
|
|
44
|
+
'removeBlobs',
|
|
45
|
+
'removeStyleTags',
|
|
46
|
+
'removeScriptTags',
|
|
47
|
+
'asyncScriptTags',
|
|
48
|
+
'userAgent',
|
|
49
|
+
'viewport',
|
|
50
|
+
'saveAs',
|
|
51
|
+
'destination',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// react-snap options that cannot be carried over, with the reason shown to the user.
|
|
55
|
+
const REACT_SNAP_IGNORED = {
|
|
56
|
+
port: 'the prerenderer serves the build itself on an ephemeral port',
|
|
57
|
+
sourceMaps: 'react-snap declares it but never uses it',
|
|
58
|
+
};
|
|
59
|
+
|
|
18
60
|
/**
|
|
19
61
|
* Reads what the project already says about itself: the framework that decides the build
|
|
20
62
|
* directory, whether Vite is in use, which package runner it uses, and whether this package
|
|
@@ -26,13 +68,61 @@ export async function detectProjectDefaults(dir = process.cwd()) {
|
|
|
26
68
|
const usesVite = Boolean(dependencies.vite) || (await anyExists(dir, VITE_CONFIGS));
|
|
27
69
|
const usesCreateReactApp = Boolean(dependencies['react-scripts']) && !usesVite;
|
|
28
70
|
const vite = usesVite ? await detectViteConfig(dir) : null;
|
|
29
|
-
|
|
71
|
+
const detected = {
|
|
30
72
|
sourceDir: vite?.sourceDir ?? (usesCreateReactApp ? 'build' : 'dist'),
|
|
31
73
|
base: vite?.base ?? homepageBase(manifest?.homepage) ?? '/',
|
|
32
74
|
usesVite,
|
|
33
75
|
runner: await detectRunner(dir),
|
|
34
76
|
installed: PACKAGE_NAME in dependencies,
|
|
35
77
|
};
|
|
78
|
+
const reactSnap = mapReactSnapOptions(manifest?.reactSnap);
|
|
79
|
+
if (reactSnap) detected.reactSnap = reactSnap;
|
|
80
|
+
return detected;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Maps a legacy react-snap block onto this package's options. The result lists what
|
|
85
|
+
* carried over, what was deliberately dropped and what was not recognised, so the
|
|
86
|
+
* wizard can explain the migration instead of guessing.
|
|
87
|
+
*/
|
|
88
|
+
export function mapReactSnapOptions(block) {
|
|
89
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) return null;
|
|
90
|
+
const values = {};
|
|
91
|
+
const mapping = [];
|
|
92
|
+
const ignored = [];
|
|
93
|
+
const unknown = [];
|
|
94
|
+
for (const [key, value] of Object.entries(block)) {
|
|
95
|
+
if (Object.hasOwn(REACT_SNAP_IGNORED, key)) {
|
|
96
|
+
ignored.push({ key, reason: REACT_SNAP_IGNORED[key] });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const target = REACT_SNAP_RENAMED[key] ?? (REACT_SNAP_PASSTHROUGH.has(key) ? key : null);
|
|
100
|
+
if (target === null) {
|
|
101
|
+
unknown.push(key);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// JSON cannot carry functions or RegExp, but a hand-written manifest may.
|
|
105
|
+
if (!isSerialisable(value)) {
|
|
106
|
+
unknown.push(`${key} (unsupported value)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const mappedValue =
|
|
110
|
+
target === 'include' && typeof value === 'string' ? parseRouteList(value) : value;
|
|
111
|
+
values[target] = mappedValue;
|
|
112
|
+
mapping.push({ from: key, to: target, value: mappedValue });
|
|
113
|
+
}
|
|
114
|
+
return { values, mapping, ignored, unknown };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reports whether a value can be written into a JavaScript config file.
|
|
118
|
+
function isSerialisable(value) {
|
|
119
|
+
if (value === null) return true;
|
|
120
|
+
if (['string', 'number', 'boolean'].includes(typeof value)) return true;
|
|
121
|
+
if (Array.isArray(value)) return value.every(isSerialisable);
|
|
122
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
123
|
+
return Object.values(value).every(isSerialisable);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
36
126
|
}
|
|
37
127
|
|
|
38
128
|
/**
|
|
@@ -87,46 +177,74 @@ export function parseRouteList(input) {
|
|
|
87
177
|
return routes.length > 0 ? routes : ['/'];
|
|
88
178
|
}
|
|
89
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Turns the wizard's answers into the options object the generated config will export.
|
|
182
|
+
* Only values that change a default are included, and only options the resolver knows.
|
|
183
|
+
*/
|
|
184
|
+
export function configValuesFromAnswers(answers = {}) {
|
|
185
|
+
const values = { sourceDir: answers.sourceDir ?? 'dist' };
|
|
186
|
+
if (answers.base && answers.base !== '/') values.base = answers.base;
|
|
187
|
+
// A supplied include list acts as an allowlist, so writing the default would stop discovery.
|
|
188
|
+
const includeValue = answers.include ?? ['/'];
|
|
189
|
+
const include = parseRouteList(
|
|
190
|
+
Array.isArray(includeValue) ? includeValue.join(',') : includeValue,
|
|
191
|
+
);
|
|
192
|
+
const includeIsDefault = include.length === 1 && include[0] === '/';
|
|
193
|
+
if (!includeIsDefault) values.include = include;
|
|
194
|
+
if (answers.crawl === false) values.crawl = false;
|
|
195
|
+
if (answers.csp === 'strict' || answers.csp === 'hash') values.csp = answers.csp;
|
|
196
|
+
if (answers.externalScripts === true) values.externalScripts = true;
|
|
197
|
+
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
198
|
+
values.criticalCssPreload = answers.criticalCssPreload;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
202
|
+
if (metadata.length > 0) values.metadata = Object.fromEntries(metadata);
|
|
203
|
+
|
|
204
|
+
for (const [key, value] of Object.entries(answers.extraOptions ?? {})) {
|
|
205
|
+
if (!Object.hasOwn(DEFAULTS, key) || key === 'metadata') continue;
|
|
206
|
+
if (Object.hasOwn(values, key)) continue;
|
|
207
|
+
if (!isSerialisable(value)) continue;
|
|
208
|
+
values[key] = value;
|
|
209
|
+
}
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
|
|
90
213
|
/**
|
|
91
214
|
* Renders the config file for the wizard's answers, writing only the options the answers
|
|
92
215
|
* actually set so every line in the file does something.
|
|
93
216
|
*/
|
|
94
217
|
export function renderConfigFile(answers = {}) {
|
|
218
|
+
const values = configValuesFromAnswers(answers);
|
|
95
219
|
const lines = [
|
|
96
220
|
'// Created by `snappy-prerender init`. Every option is documented in the snappy-prerender README.',
|
|
97
221
|
'export default {',
|
|
98
|
-
` sourceDir: ${quote(answers.sourceDir ?? 'dist')},`,
|
|
99
222
|
];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!includeIsDefault) lines.push(` include: [${include.map(quote).join(', ')}],`);
|
|
106
|
-
if (answers.crawl === false) lines.push(' crawl: false,');
|
|
107
|
-
// The CSP dial and the parts it was tuned with, so a strict-CSP project keeps the choice.
|
|
108
|
-
if (answers.csp === 'strict') lines.push(' csp: "strict",');
|
|
109
|
-
if (answers.externalScripts === true) lines.push(' externalScripts: true,');
|
|
110
|
-
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
111
|
-
// A boolean stays a literal; a mode name is quoted.
|
|
112
|
-
const value =
|
|
113
|
-
typeof answers.criticalCssPreload === 'boolean'
|
|
114
|
-
? String(answers.criticalCssPreload)
|
|
115
|
-
: quote(answers.criticalCssPreload);
|
|
116
|
-
lines.push(` criticalCssPreload: ${value},`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
120
|
-
if (metadata.length > 0) {
|
|
223
|
+
for (const [key, value] of Object.entries(values)) {
|
|
224
|
+
if (key !== 'metadata') {
|
|
225
|
+
lines.push(` ${key}: ${serializeValue(value)},`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
121
228
|
lines.push(' metadata: {');
|
|
122
|
-
for (const [
|
|
229
|
+
for (const [metadataKey, metadataValue] of Object.entries(value)) {
|
|
230
|
+
lines.push(` ${metadataKey}: ${serializeValue(metadataValue)},`);
|
|
231
|
+
}
|
|
123
232
|
lines.push(' },');
|
|
124
233
|
}
|
|
125
|
-
|
|
126
234
|
lines.push('};', '');
|
|
127
235
|
return lines.join('\n');
|
|
128
236
|
}
|
|
129
237
|
|
|
238
|
+
// Writes a JSON-compatible value as JavaScript source with a stable, readable shape.
|
|
239
|
+
function serializeValue(value) {
|
|
240
|
+
if (Array.isArray(value)) return `[${value.map(serializeValue).join(', ')}]`;
|
|
241
|
+
if (value !== null && typeof value === 'object') {
|
|
242
|
+
const entries = Object.entries(value).map(([key, entry]) => `${key}: ${serializeValue(entry)}`);
|
|
243
|
+
return `{ ${entries.join(', ')} }`;
|
|
244
|
+
}
|
|
245
|
+
return JSON.stringify(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
130
248
|
/**
|
|
131
249
|
* Writes the config file into the project, refusing to replace an existing one unless the
|
|
132
250
|
* caller asked for it, so a wizard run can never silently destroy hand-written settings.
|
|
@@ -140,11 +258,6 @@ export async function writeConfigFile({ dir, contents, force = false }) {
|
|
|
140
258
|
return target;
|
|
141
259
|
}
|
|
142
260
|
|
|
143
|
-
// Quotes a string for the generated module.
|
|
144
|
-
function quote(value) {
|
|
145
|
-
return JSON.stringify(String(value));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
261
|
// Reads a JSON file, treating a missing or malformed file as absent.
|
|
149
262
|
async function readJson(file) {
|
|
150
263
|
try {
|
package/src/core/inline-css.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { hasImportRule, rewriteCssUrls } from './css-url.js';
|
|
2
3
|
import { minifyCss } from './minify.js';
|
|
3
4
|
|
|
@@ -8,8 +9,9 @@ import { minifyCss } from './minify.js';
|
|
|
8
9
|
* place. Relative url() values are rebased against each sheet before joining so fonts
|
|
9
10
|
* and images keep working on nested routes.
|
|
10
11
|
*/
|
|
11
|
-
export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
12
|
-
const
|
|
12
|
+
export async function inlineStylesheets({ page, minifyCssOptions, cache = null }) {
|
|
13
|
+
const cachedCss = cache ? Object.fromEntries(cache) : {};
|
|
14
|
+
const collected = await page.evaluate(async (knownCss) => {
|
|
13
15
|
// Only media that applies on screen can be inlined without changing how the sheet
|
|
14
16
|
// applies, so a print stylesheet is left as a link instead of becoming active.
|
|
15
17
|
const INLINEABLE_MEDIA = new Set(['', 'all', 'screen']);
|
|
@@ -22,24 +24,30 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
22
24
|
skipped.push(link.href);
|
|
23
25
|
continue;
|
|
24
26
|
}
|
|
27
|
+
if (Object.hasOwn(knownCss, link.href)) {
|
|
28
|
+
sheets.push({ css: knownCss[link.href], href: link.href, fetched: false });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
25
31
|
try {
|
|
26
32
|
const response = await fetch(link.href);
|
|
27
33
|
if (!response.ok) throw new Error(String(response.status));
|
|
28
|
-
sheets.push({ css: await response.text(), href: link.href });
|
|
34
|
+
sheets.push({ css: await response.text(), href: link.href, fetched: true });
|
|
29
35
|
} catch {
|
|
30
36
|
skipped.push(link.href);
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
return { sheets, skipped };
|
|
34
|
-
});
|
|
40
|
+
}, cachedCss);
|
|
35
41
|
|
|
36
42
|
const inlineable = [];
|
|
37
43
|
const skipped = [...collected.skipped];
|
|
44
|
+
const fetched = [];
|
|
38
45
|
for (const sheet of collected.sheets) {
|
|
46
|
+
if (sheet.fetched) fetched.push({ href: sheet.href, css: sheet.css });
|
|
39
47
|
if (hasImportRule(sheet.css)) skipped.push(sheet.href);
|
|
40
48
|
else inlineable.push(sheet);
|
|
41
49
|
}
|
|
42
|
-
if (inlineable.length === 0) return { skipped };
|
|
50
|
+
if (inlineable.length === 0) return { skipped, fetched };
|
|
43
51
|
|
|
44
52
|
const pageOrigin = new URL(page.url()).origin;
|
|
45
53
|
const joined = inlineable
|
|
@@ -59,7 +67,7 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
59
67
|
},
|
|
60
68
|
{ text: css, hrefs: inlineable.map((sheet) => sheet.href) },
|
|
61
69
|
);
|
|
62
|
-
return { skipped };
|
|
70
|
+
return { skipped, fetched };
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
@@ -68,7 +76,43 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
68
76
|
* beasties is an optional peer dependency because most projects never need it.
|
|
69
77
|
* `preload` is passed through so a strict script-src can turn the swap handlers off.
|
|
70
78
|
*/
|
|
71
|
-
export async function inlineCriticalCss({
|
|
79
|
+
export async function inlineCriticalCss({
|
|
80
|
+
html,
|
|
81
|
+
outputDir,
|
|
82
|
+
base,
|
|
83
|
+
preload = 'media',
|
|
84
|
+
cache = null,
|
|
85
|
+
}) {
|
|
86
|
+
const key = `${path.resolve(outputDir)}|${base}|${preload}`;
|
|
87
|
+
let state = cache?.get(key);
|
|
88
|
+
if (!state) {
|
|
89
|
+
state = {
|
|
90
|
+
processor: createCriticalCssProcessor({ outputDir, base, preload }),
|
|
91
|
+
tail: Promise.resolve(),
|
|
92
|
+
};
|
|
93
|
+
cache?.set(key, state);
|
|
94
|
+
}
|
|
95
|
+
// Beasties keeps per-instance selector state and is not safe for concurrent process
|
|
96
|
+
// calls, so this queue serialises routes while sharing the instance.
|
|
97
|
+
const run = state.tail.then(async () => {
|
|
98
|
+
const beasties = await state.processor;
|
|
99
|
+
return beasties.process(html);
|
|
100
|
+
});
|
|
101
|
+
state.tail = run.then(
|
|
102
|
+
() => undefined,
|
|
103
|
+
() => undefined,
|
|
104
|
+
);
|
|
105
|
+
try {
|
|
106
|
+
return await run;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (cache?.get(key) === state) cache.delete(key);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Creates the Beasties processor once per output configuration. The selector cache is
|
|
114
|
+
// reused across routes; stylesheet text is still read per page by the library.
|
|
115
|
+
async function createCriticalCssProcessor({ outputDir, base, preload }) {
|
|
72
116
|
let Beasties;
|
|
73
117
|
try {
|
|
74
118
|
({ default: Beasties } = await import('beasties'));
|
|
@@ -77,12 +121,11 @@ export async function inlineCriticalCss({ html, outputDir, base, preload = 'medi
|
|
|
77
121
|
"inlineCss: 'critical' needs the optional beasties dependency. Install it with: npm install beasties",
|
|
78
122
|
);
|
|
79
123
|
}
|
|
80
|
-
|
|
124
|
+
return new Beasties({
|
|
81
125
|
path: outputDir,
|
|
82
126
|
publicPath: base,
|
|
83
127
|
logLevel: 'silent',
|
|
84
128
|
preload,
|
|
85
129
|
noscriptFallback: true,
|
|
86
130
|
});
|
|
87
|
-
return beasties.process(html);
|
|
88
131
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveSeoLocale,
|
|
3
|
+
seedContextCookie,
|
|
4
|
+
seedCookies,
|
|
5
|
+
seedLocalStorage,
|
|
6
|
+
seedPageStorage,
|
|
7
|
+
} from './seo-locale.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds the Playwright context options that make one locale deterministic: browser
|
|
11
|
+
* locale, timezone and the base storage state. The caller keeps auth storage separate from
|
|
12
|
+
* locale cookies and local storage.
|
|
13
|
+
* @param {object} config
|
|
14
|
+
* @param {object | null} locale
|
|
15
|
+
* @returns {{ locale?: string, timezoneId?: string }}
|
|
16
|
+
*/
|
|
17
|
+
export function localeContextOptions(config, locale) {
|
|
18
|
+
const options = {};
|
|
19
|
+
const browserLocale = locale?.locale ?? null;
|
|
20
|
+
if (browserLocale) options.locale = browserLocale;
|
|
21
|
+
const timezone = locale?.timezone ?? config.timezone ?? null;
|
|
22
|
+
if (timezone) options.timezoneId = timezone;
|
|
23
|
+
return options;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the storage state a locale should use, preferring its own file over the global
|
|
28
|
+
* authenticated state.
|
|
29
|
+
* @param {object} config
|
|
30
|
+
* @param {object | null} locale
|
|
31
|
+
* @returns {string | undefined}
|
|
32
|
+
*/
|
|
33
|
+
export function localeStorageState(config, locale) {
|
|
34
|
+
return locale?.storageState ?? config.storageState ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Seeds the extra cookies and local storage declared on a locale record. These are
|
|
39
|
+
* independent of the SEO locale recipe and let a locale carry app-specific keys.
|
|
40
|
+
* @param {{ page: import('playwright-core').Page, context: import('playwright-core').BrowserContext, origin: string, locale: object | null }} input
|
|
41
|
+
*/
|
|
42
|
+
export async function seedLocaleExtras({ page, context, origin, locale }) {
|
|
43
|
+
if (!locale) return;
|
|
44
|
+
await seedCookies(context, origin, locale.cookies ?? []);
|
|
45
|
+
await seedLocalStorage(page, locale.localStorage ?? {}, true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Seeds the cookie and local storage the app reads before it boots. Render and verification
|
|
50
|
+
* use the same recipe, so the selected locale is identical in both passes.
|
|
51
|
+
* @param {{ page: import('playwright-core').Page, context: import('playwright-core').BrowserContext, origin: string, config: object, route: string, locale: object | null, phase: 'render' | 'verify' }} input
|
|
52
|
+
* @returns {Promise<object | null>} the resolved SEO locale, when one applied
|
|
53
|
+
*/
|
|
54
|
+
export async function seedLocaleContext({ page, context, origin, config, route, locale, phase }) {
|
|
55
|
+
await seedLocaleExtras({ page, context, origin, locale });
|
|
56
|
+
const seo = resolveSeoLocale(config, locale, route);
|
|
57
|
+
if (!seo) return null;
|
|
58
|
+
if (phase === 'render' && !seo.seedRender) return null;
|
|
59
|
+
if (phase === 'verify' && !seo.seedVerification) return null;
|
|
60
|
+
await seedPageStorage(page, seo);
|
|
61
|
+
await seedContextCookie(context, origin, seo);
|
|
62
|
+
return seo;
|
|
63
|
+
}
|