snappy-prerender 2.7.0 → 2.8.1
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 +55 -0
- package/README.md +168 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- 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 +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- 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 +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
package/src/core/crawl.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
activeLocale,
|
|
5
|
+
isLocalePrefixedRoute,
|
|
6
|
+
localeByCode,
|
|
7
|
+
localeRoute,
|
|
8
|
+
stripLocalePrefix,
|
|
9
|
+
} from './locales.js';
|
|
3
10
|
import { renderRoute } from './render.js';
|
|
4
11
|
import { resolveRouteConfig } from './route-config.js';
|
|
5
12
|
import {
|
|
@@ -24,12 +31,37 @@ export async function renderAllRoutes({
|
|
|
24
31
|
report,
|
|
25
32
|
outputDir,
|
|
26
33
|
sourceSeeds = [],
|
|
34
|
+
caches = null,
|
|
35
|
+
localePlan = null,
|
|
27
36
|
}) {
|
|
28
37
|
const visited = new Set();
|
|
29
38
|
const scheduled = new Set();
|
|
30
39
|
const rendered = new Map();
|
|
40
|
+
const routeLocales = new Map();
|
|
31
41
|
const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
|
|
32
42
|
let nextOrder = 0;
|
|
43
|
+
const defaultLocale = localePlan ? localeByCode(localePlan, localePlan.default) : null;
|
|
44
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
45
|
+
|
|
46
|
+
// Expands a base route into every locale variant it publishes. The not-found route is
|
|
47
|
+
// deliberately not localized: a host needs one 404 document.
|
|
48
|
+
const variantsFor = (route) => {
|
|
49
|
+
if (!localePlan) return [{ route, locale: null, baseRoute: route }];
|
|
50
|
+
if (localePlan.mode === 'single') {
|
|
51
|
+
return [{ route, locale: activeLocale(localePlan), baseRoute: route }];
|
|
52
|
+
}
|
|
53
|
+
const baseRoute = defaultLocale ? stripLocalePrefix(route, defaultLocale) : route;
|
|
54
|
+
const isNotFound = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
|
|
55
|
+
const variants = [];
|
|
56
|
+
for (const locale of localePlan.list) {
|
|
57
|
+
if (isNotFound && locale !== defaultLocale) continue;
|
|
58
|
+
const localized = isNotFound
|
|
59
|
+
? normaliseRoute(config.notFoundRoute)
|
|
60
|
+
: localeRoute(locale, baseRoute);
|
|
61
|
+
variants.push({ route: localized, locale, baseRoute });
|
|
62
|
+
}
|
|
63
|
+
return variants;
|
|
64
|
+
};
|
|
33
65
|
|
|
34
66
|
// Queues routes up to maxRoutes, recording exactly which candidates did not fit.
|
|
35
67
|
// A seed used only to reach wildcard routes is given output: false and is rendered
|
|
@@ -38,14 +70,24 @@ export async function renderAllRoutes({
|
|
|
38
70
|
const accepted = [];
|
|
39
71
|
const dropped = [];
|
|
40
72
|
for (const route of routes) {
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
if (isLocalePrefixedRoute(route, localePlan)) continue;
|
|
74
|
+
for (const variant of variantsFor(route)) {
|
|
75
|
+
if (scheduled.has(variant.route)) continue;
|
|
76
|
+
if (scheduled.size >= cap) {
|
|
77
|
+
dropped.push(variant.route);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
scheduled.add(variant.route);
|
|
81
|
+
routeLocales.set(variant.route, variant);
|
|
82
|
+
accepted.push({
|
|
83
|
+
route: variant.route,
|
|
84
|
+
output: outputFor(variant.baseRoute),
|
|
85
|
+
order: nextOrder,
|
|
86
|
+
locale: variant.locale,
|
|
87
|
+
baseRoute: variant.baseRoute,
|
|
88
|
+
});
|
|
89
|
+
nextOrder += 1;
|
|
45
90
|
}
|
|
46
|
-
scheduled.add(route);
|
|
47
|
-
accepted.push({ route, output: outputFor(route), order: nextOrder });
|
|
48
|
-
nextOrder += 1;
|
|
49
91
|
}
|
|
50
92
|
if (dropped.length > 0) {
|
|
51
93
|
const previous = report.truncated?.droppedRoutes ?? [];
|
|
@@ -58,7 +100,14 @@ export async function renderAllRoutes({
|
|
|
58
100
|
return accepted;
|
|
59
101
|
};
|
|
60
102
|
|
|
61
|
-
const renderOne = async ({
|
|
103
|
+
const renderOne = async ({
|
|
104
|
+
route,
|
|
105
|
+
output,
|
|
106
|
+
depth = 0,
|
|
107
|
+
order = null,
|
|
108
|
+
locale = null,
|
|
109
|
+
baseRoute = route,
|
|
110
|
+
}) => {
|
|
62
111
|
if (visited.has(route)) return null;
|
|
63
112
|
visited.add(route);
|
|
64
113
|
log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
|
|
@@ -73,13 +122,15 @@ export async function renderAllRoutes({
|
|
|
73
122
|
report,
|
|
74
123
|
outputDir,
|
|
75
124
|
screenshot: config.saveAs !== 'html',
|
|
125
|
+
caches,
|
|
126
|
+
locale,
|
|
76
127
|
});
|
|
77
128
|
if (!result) return null;
|
|
78
129
|
result.depth = depth;
|
|
79
130
|
result.order = order;
|
|
80
131
|
try {
|
|
81
132
|
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
82
|
-
const isNotFoundRoute = notFoundRoute !== null &&
|
|
133
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
|
|
83
134
|
if (result.statusCode === 404 && !isNotFoundRoute) {
|
|
84
135
|
report.errors.push({ route, message: 'returned 404' });
|
|
85
136
|
log.error(` route ${route} returned 404`);
|
|
@@ -147,7 +198,12 @@ export async function renderAllRoutes({
|
|
|
147
198
|
if (sourceSeeds.length === 0 || config.includeProvided) {
|
|
148
199
|
candidates.push(...crawlRoots(config));
|
|
149
200
|
}
|
|
150
|
-
const roots = accept(
|
|
201
|
+
const roots = accept(
|
|
202
|
+
[...new Set(sortRoutes(candidates, config))].filter(
|
|
203
|
+
(route) => !isLocalePrefixedRoute(route, localePlan),
|
|
204
|
+
),
|
|
205
|
+
outputFor,
|
|
206
|
+
);
|
|
151
207
|
|
|
152
208
|
const deterministic = config.maxRoutes !== null || config.routePriority.length > 0;
|
|
153
209
|
if (deterministic) {
|
|
@@ -155,15 +211,17 @@ export async function renderAllRoutes({
|
|
|
155
211
|
} else {
|
|
156
212
|
const pool = createPool({
|
|
157
213
|
concurrency: config.concurrency,
|
|
158
|
-
handler: async ({ route, depth, output, order }) => {
|
|
159
|
-
const renderedRoute = await renderOne({ route, output, depth, order });
|
|
214
|
+
handler: async ({ route, depth, output, order, locale, baseRoute }) => {
|
|
215
|
+
const renderedRoute = await renderOne({ route, output, depth, order, locale, baseRoute });
|
|
160
216
|
if (!renderedRoute) return;
|
|
161
217
|
const { result, routeConfig } = renderedRoute;
|
|
162
218
|
if (routeConfig.crawl && withinDepth(depth + 1, routeConfig.maxDepth)) {
|
|
163
219
|
const discovered = filterRoutes(result.links, {
|
|
164
220
|
include: config.includeProvided ? config.include : [],
|
|
165
221
|
exclude: config.exclude,
|
|
166
|
-
})
|
|
222
|
+
})
|
|
223
|
+
.filter((candidate) => !isLocalePrefixedRoute(candidate, localePlan))
|
|
224
|
+
.filter((candidate) => !scheduled.has(candidate));
|
|
167
225
|
const accepted = accept(sortRoutes(discovered, config));
|
|
168
226
|
pool.push(accepted.map((item) => ({ ...item, depth: depth + 1 })));
|
|
169
227
|
}
|
|
@@ -176,7 +234,7 @@ export async function renderAllRoutes({
|
|
|
176
234
|
log.error(` pool error on ${item.route}: ${error.message}`);
|
|
177
235
|
}
|
|
178
236
|
}
|
|
179
|
-
return rendered;
|
|
237
|
+
return { rendered, routeLocales };
|
|
180
238
|
}
|
|
181
239
|
|
|
182
240
|
/**
|
|
@@ -194,6 +252,8 @@ async function renderWithRetries({
|
|
|
194
252
|
report,
|
|
195
253
|
outputDir,
|
|
196
254
|
screenshot,
|
|
255
|
+
caches,
|
|
256
|
+
locale,
|
|
197
257
|
}) {
|
|
198
258
|
const attempts = config.retries + 1;
|
|
199
259
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
@@ -206,9 +266,11 @@ async function renderWithRetries({
|
|
|
206
266
|
browser,
|
|
207
267
|
origin: server.origin,
|
|
208
268
|
route,
|
|
269
|
+
locale,
|
|
209
270
|
config: routeConfig,
|
|
210
271
|
screenshot,
|
|
211
272
|
traceFile,
|
|
273
|
+
caches,
|
|
212
274
|
});
|
|
213
275
|
if (attempt > 1) log.info(` ${route}: succeeded on attempt ${attempt}`);
|
|
214
276
|
result.attempts = attempt;
|
package/src/core/csp.js
CHANGED
|
@@ -20,11 +20,23 @@ const CSP_MARKER = 'data-snappy-csp';
|
|
|
20
20
|
*/
|
|
21
21
|
export function applyCspHashes(html) {
|
|
22
22
|
const document = parse(html);
|
|
23
|
+
const result = applyCspHashesToDocument(document);
|
|
24
|
+
return {
|
|
25
|
+
html: result.existingPolicy ? html : serialize(document),
|
|
26
|
+
...result,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Applies the hash policy to an already parsed document. Returns counts and whether a
|
|
32
|
+
* user-owned policy was left untouched.
|
|
33
|
+
*/
|
|
34
|
+
export function applyCspHashesToDocument(document) {
|
|
23
35
|
const head = findHead(document);
|
|
24
|
-
if (!head) return {
|
|
36
|
+
if (!head) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: false };
|
|
25
37
|
removeGeneratedPolicy(head);
|
|
26
38
|
const existing = findPolicyMeta(head);
|
|
27
|
-
if (existing) return {
|
|
39
|
+
if (existing) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: true };
|
|
28
40
|
|
|
29
41
|
const scriptHashes = [];
|
|
30
42
|
const styleHashes = [];
|
|
@@ -43,7 +55,6 @@ export function applyCspHashes(html) {
|
|
|
43
55
|
];
|
|
44
56
|
head.childNodes.unshift(metaNode(directives.join('; ')));
|
|
45
57
|
return {
|
|
46
|
-
html: serialize(document),
|
|
47
58
|
hashedScripts: scriptHashes.length,
|
|
48
59
|
hashedStyles: styleHashes.length,
|
|
49
60
|
existingPolicy: false,
|
package/src/core/destination.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { mapLimit } from './scheduler.js';
|
|
4
|
+
|
|
5
|
+
const COPY_CONCURRENCY = 8;
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Prepares the directory the run writes into. When a destination is configured the
|
|
@@ -58,13 +61,26 @@ export async function prepareDestination({
|
|
|
58
61
|
async function copyTree(source, target) {
|
|
59
62
|
await fs.mkdir(target, { recursive: true });
|
|
60
63
|
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
61
|
-
|
|
62
|
-
for (const entry of entries) {
|
|
64
|
+
const { results, errors } = await mapLimit(entries, COPY_CONCURRENCY, async (entry) => {
|
|
63
65
|
const from = path.join(source, entry.name);
|
|
64
66
|
const to = path.join(target, entry.name);
|
|
65
|
-
if (entry.isDirectory())
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (entry.isDirectory()) return copyTree(from, to);
|
|
68
|
+
if (!entry.isFile()) return 1;
|
|
69
|
+
const [sourceStats, targetStats] = await Promise.all([
|
|
70
|
+
fs.stat(from),
|
|
71
|
+
fs.stat(to).catch(() => null),
|
|
72
|
+
]);
|
|
73
|
+
if (
|
|
74
|
+
targetStats?.isFile() &&
|
|
75
|
+
targetStats.size === sourceStats.size &&
|
|
76
|
+
targetStats.mtimeMs === sourceStats.mtimeMs
|
|
77
|
+
) {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
await fs.copyFile(from, to);
|
|
81
|
+
await fs.utimes(to, sourceStats.atime, sourceStats.mtime);
|
|
82
|
+
return 0;
|
|
83
|
+
});
|
|
84
|
+
if (errors.length > 0) throw errors[0].error;
|
|
85
|
+
return results.reduce((total, skipped) => total + (skipped ?? 0), 0);
|
|
70
86
|
}
|
|
@@ -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/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
|
+
}
|