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/render.js
CHANGED
|
@@ -1,24 +1,13 @@
|
|
|
1
|
+
import { installClock, installClockScripts } from './clock.js';
|
|
1
2
|
import { captureFormState } from './form-state.js';
|
|
2
3
|
import { inlineStylesheets } from './inline-css.js';
|
|
3
|
-
import {
|
|
4
|
+
import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
|
|
5
|
+
import { readPageState } from './page-content.js';
|
|
4
6
|
import { createResourceCollector } from './resources.js';
|
|
5
7
|
import { normaliseRoute, toAppRoute, toServerUrl } from './routes.js';
|
|
6
8
|
import { captureRuntimeStyles } from './runtime-styles.js';
|
|
7
9
|
import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const SCRIPT_ESCAPES = {
|
|
11
|
-
'<': '\\u003C',
|
|
12
|
-
'>': '\\u003E',
|
|
13
|
-
'/': '\\u002F',
|
|
14
|
-
'\u2028': '\\u2028',
|
|
15
|
-
'\u2029': '\\u2029',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// Serialises a value for embedding in a script element without allowing tag breakout.
|
|
19
|
-
function escapeForScript(value) {
|
|
20
|
-
return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_ESCAPES[char]);
|
|
21
|
-
}
|
|
10
|
+
import { readRenderSnapshot } from './text-separators.js';
|
|
22
11
|
|
|
23
12
|
/**
|
|
24
13
|
* Renders one route in a fresh browser context and returns the serialised DOM plus
|
|
@@ -30,17 +19,20 @@ export async function renderRoute({
|
|
|
30
19
|
browser,
|
|
31
20
|
origin,
|
|
32
21
|
route,
|
|
22
|
+
locale = null,
|
|
33
23
|
config,
|
|
34
24
|
screenshot = false,
|
|
35
25
|
traceFile = null,
|
|
26
|
+
caches = null,
|
|
36
27
|
}) {
|
|
37
28
|
const context = await browser.newContext({
|
|
38
29
|
viewport: config.viewport,
|
|
39
|
-
storageState: config
|
|
30
|
+
storageState: localeStorageState(config, locale),
|
|
40
31
|
userAgent: config.userAgent ?? undefined,
|
|
41
32
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
42
33
|
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
43
34
|
serviceWorkers: 'block',
|
|
35
|
+
...localeContextOptions(config, locale),
|
|
44
36
|
});
|
|
45
37
|
let tracing = false;
|
|
46
38
|
if (traceFile) {
|
|
@@ -48,6 +40,11 @@ export async function renderRoute({
|
|
|
48
40
|
tracing = true;
|
|
49
41
|
}
|
|
50
42
|
const page = await context.newPage();
|
|
43
|
+
await installClock(page, config.fixedTime ?? null);
|
|
44
|
+
await installClockScripts(page, {
|
|
45
|
+
fixedTime: config.fixedTime ?? null,
|
|
46
|
+
randomSeed: config.randomSeed ?? null,
|
|
47
|
+
});
|
|
51
48
|
const pageErrors = [];
|
|
52
49
|
const consoleErrors = [];
|
|
53
50
|
const failedRequests = [];
|
|
@@ -75,6 +72,15 @@ export async function renderRoute({
|
|
|
75
72
|
const metadataDefaults = config.metadata
|
|
76
73
|
? { base: config.base, flatOutput: config.flatOutput, ...config.metadata }
|
|
77
74
|
: null;
|
|
75
|
+
await seedLocaleContext({
|
|
76
|
+
page,
|
|
77
|
+
context,
|
|
78
|
+
origin,
|
|
79
|
+
config,
|
|
80
|
+
route,
|
|
81
|
+
locale,
|
|
82
|
+
phase: 'render',
|
|
83
|
+
});
|
|
78
84
|
if (metadataDefaults) {
|
|
79
85
|
await page.addInitScript((defaults) => {
|
|
80
86
|
window.__SNAPPY_META__ = defaults;
|
|
@@ -127,20 +133,21 @@ export async function renderRoute({
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
await settlePage(page, config, tracker);
|
|
130
|
-
const empty = await
|
|
131
|
-
const statusCode = await page.evaluate(() => {
|
|
132
|
-
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
133
|
-
if (!marker) return null;
|
|
134
|
-
const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
|
|
135
|
-
return Number.isFinite(value) ? value : null;
|
|
136
|
-
});
|
|
136
|
+
const { empty, statusCode } = await readPageState(page);
|
|
137
137
|
const finalUrl = page.url();
|
|
138
138
|
|
|
139
139
|
if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
|
|
140
140
|
if (config.captureFormState) await captureFormState(page);
|
|
141
141
|
if (config.inlineCss === true || config.inlineCss === 'inline') {
|
|
142
|
-
const inlined = await inlineStylesheets({
|
|
142
|
+
const inlined = await inlineStylesheets({
|
|
143
|
+
page,
|
|
144
|
+
minifyCssOptions: config.minifyCss,
|
|
145
|
+
cache: caches?.inlineCss ?? null,
|
|
146
|
+
});
|
|
143
147
|
inlineCssSkipped = inlined.skipped;
|
|
148
|
+
if (caches?.inlineCss) {
|
|
149
|
+
for (const sheet of inlined.fetched ?? []) caches.inlineCss.set(sheet.href, sheet.css);
|
|
150
|
+
}
|
|
144
151
|
}
|
|
145
152
|
await collector.settled();
|
|
146
153
|
const stateScript = await injectCapturedState(page, collector.json, {
|
|
@@ -150,7 +157,7 @@ export async function renderRoute({
|
|
|
150
157
|
if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
|
|
151
158
|
if (config.freezeAnimations) await freezeAnimations(page);
|
|
152
159
|
if (config.scrollToBottom) await scrollThroughPage(page, config);
|
|
153
|
-
const lcpUrl = await page
|
|
160
|
+
const { lcpUrl, hrefs } = await readRenderSnapshot(page);
|
|
154
161
|
let lcpImage = null;
|
|
155
162
|
if (lcpUrl) {
|
|
156
163
|
try {
|
|
@@ -160,9 +167,7 @@ export async function renderRoute({
|
|
|
160
167
|
// Ignore malformed performance entries.
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const links = await collectLinks(page, config.base, origin);
|
|
170
|
+
const links = collectLinks(hrefs, config.base, origin);
|
|
166
171
|
|
|
167
172
|
if (screenshot) {
|
|
168
173
|
const buffer = await page.screenshot({
|
|
@@ -311,16 +316,21 @@ async function injectCapturedState(page, cache, { externalScripts = false, base
|
|
|
311
316
|
* executable script, so a strict script-src applies to nothing here.
|
|
312
317
|
*/
|
|
313
318
|
async function injectMetadataDefaults(page, defaults) {
|
|
314
|
-
|
|
319
|
+
await page.evaluate((fallback) => {
|
|
315
320
|
document.querySelectorAll('script[data-snappy-meta]').forEach((script) => script.remove());
|
|
316
321
|
const current = typeof window === 'undefined' ? null : window.__SNAPPY_META__;
|
|
317
322
|
const recorded = typeof window === 'undefined' ? null : window.__snappyHeadOriginals;
|
|
318
323
|
const merged = { ...(current ?? fallback) };
|
|
319
324
|
if (recorded) merged.originals = recorded;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
const payload = Object.keys(merged).length > 0 ? merged : fallback;
|
|
326
|
+
const escapes = {
|
|
327
|
+
'<': '\\u003C',
|
|
328
|
+
'>': '\\u003E',
|
|
329
|
+
'/': '\\u002F',
|
|
330
|
+
'\u2028': '\\u2028',
|
|
331
|
+
'\u2029': '\\u2029',
|
|
332
|
+
};
|
|
333
|
+
const text = JSON.stringify(payload).replace(/[<>/\u2028\u2029]/g, (char) => escapes[char]);
|
|
324
334
|
const script = document.createElement('script');
|
|
325
335
|
script.setAttribute('type', 'application/json');
|
|
326
336
|
script.setAttribute('data-snappy-meta', '');
|
|
@@ -328,7 +338,7 @@ async function injectMetadataDefaults(page, defaults) {
|
|
|
328
338
|
const first = document.scripts[0];
|
|
329
339
|
if (first?.parentNode) first.parentNode.insertBefore(script, first);
|
|
330
340
|
else document.head.appendChild(script);
|
|
331
|
-
},
|
|
341
|
+
}, defaults);
|
|
332
342
|
}
|
|
333
343
|
|
|
334
344
|
/**
|
|
@@ -336,8 +346,7 @@ async function injectMetadataDefaults(page, defaults) {
|
|
|
336
346
|
* app-relative routes for the crawler. Off-origin links are ignored so external
|
|
337
347
|
* URLs can never be mistaken for routes and written into the output directory.
|
|
338
348
|
*/
|
|
339
|
-
|
|
340
|
-
const hrefs = await page.$$eval('a[href]', (anchors) => anchors.map((anchor) => anchor.href));
|
|
349
|
+
function collectLinks(hrefs, base, origin) {
|
|
341
350
|
const routes = [];
|
|
342
351
|
for (const href of hrefs) {
|
|
343
352
|
let parsed;
|
|
@@ -350,7 +359,7 @@ async function collectLinks(page, base, origin) {
|
|
|
350
359
|
const route = toAppRoute(href, base);
|
|
351
360
|
if (route) routes.push(route);
|
|
352
361
|
}
|
|
353
|
-
return routes;
|
|
362
|
+
return [...new Set(routes)];
|
|
354
363
|
}
|
|
355
364
|
|
|
356
365
|
// Parses an HTTP URL, returning null for data, blob and other non-network URLs.
|
package/src/core/report.js
CHANGED
|
@@ -34,7 +34,11 @@ export function buildReport(report, config = null) {
|
|
|
34
34
|
version: 1,
|
|
35
35
|
generatedAt: new Date().toISOString(),
|
|
36
36
|
durationMs: report.durationMs,
|
|
37
|
+
timings: report.timings,
|
|
37
38
|
ok: report.ok,
|
|
39
|
+
clock: report.clock ?? null,
|
|
40
|
+
locales: report.locales ?? null,
|
|
41
|
+
seoLocale: report.seoLocale ?? null,
|
|
38
42
|
summary: {
|
|
39
43
|
routes: report.routeDetails.length,
|
|
40
44
|
files: report.files.filter((file) => file.status !== 'dry-run').length,
|
|
@@ -48,6 +52,9 @@ export function buildReport(report, config = null) {
|
|
|
48
52
|
modes: report.verification.modes,
|
|
49
53
|
frameworks: report.verification.frameworks,
|
|
50
54
|
skipped: report.verification.skipped ?? 0,
|
|
55
|
+
policySkipped: report.verification.policySkipped ?? 0,
|
|
56
|
+
allowed: report.verification.allowed ?? 0,
|
|
57
|
+
allowedRerender: report.verification.allowedRerender ?? 0,
|
|
51
58
|
errors: report.verification.errors,
|
|
52
59
|
}
|
|
53
60
|
: null,
|
|
@@ -85,9 +92,10 @@ function sanitiseValue(value, config) {
|
|
|
85
92
|
let text = value;
|
|
86
93
|
if (config?.storageState) {
|
|
87
94
|
for (const secret of [config.storageState, path.resolve(config.storageState)]) {
|
|
88
|
-
text = text.split(secret).join('[redacted]');
|
|
95
|
+
if (text.includes(secret)) text = text.split(secret).join('[redacted]');
|
|
89
96
|
}
|
|
90
97
|
}
|
|
98
|
+
if (!/https?:\/\//i.test(text)) return text;
|
|
91
99
|
return text.replace(/https?:\/\/[^\s"'<>]+/gi, redactUrl);
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isSafeRoute, normaliseRoute, toAppRoute } from './routes.js';
|
|
4
|
+
import { mapLimit } from './scheduler.js';
|
|
4
5
|
|
|
5
6
|
const MAX_SOURCE_ROUTES = 100000;
|
|
6
7
|
const FETCH_TIMEOUT = 10000;
|
|
8
|
+
const SITEMAP_FETCH_CONCURRENCY = 4;
|
|
7
9
|
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -80,9 +82,18 @@ async function readSitemapLevel(location, pageUrls, depth) {
|
|
|
80
82
|
throw new Error(`${location} is not a sitemap: no urlset or sitemapindex element found`);
|
|
81
83
|
}
|
|
82
84
|
if (/<sitemapindex\b/i.test(text)) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
const children = extractLocs(text);
|
|
86
|
+
const { results, errors } = await mapLimit(
|
|
87
|
+
children,
|
|
88
|
+
SITEMAP_FETCH_CONCURRENCY,
|
|
89
|
+
async (child) => {
|
|
90
|
+
const routes = [];
|
|
91
|
+
await readSitemapLevel(resolveChild(child, base), routes, depth + 1);
|
|
92
|
+
return routes;
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
if (errors.length > 0) throw errors[0].error;
|
|
96
|
+
for (const routes of results) pageUrls.push(...routes);
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
99
|
pageUrls.push(...extractLocs(text));
|
package/src/core/run.js
CHANGED
|
@@ -3,9 +3,11 @@ import path from 'node:path';
|
|
|
3
3
|
import { writePreloadManifest, writeSitemap } from './artifacts.js';
|
|
4
4
|
import { auditRun } from './audit/index.js';
|
|
5
5
|
import { launchBrowser } from './browser.js';
|
|
6
|
+
import { resolveFixedTime } from './clock.js';
|
|
6
7
|
import { resolveConfig } from './config.js';
|
|
7
8
|
import { renderAllRoutes } from './crawl.js';
|
|
8
9
|
import { prepareDestination } from './destination.js';
|
|
10
|
+
import { buildLocalePlan } from './locales.js';
|
|
9
11
|
import { createLogger } from './log.js';
|
|
10
12
|
import { routeToFile } from './output.js';
|
|
11
13
|
import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
|
|
@@ -29,12 +31,17 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
29
31
|
for (const warning of config.warnings ?? []) log.warn(warning);
|
|
30
32
|
const sourceDir = path.resolve(config.sourceDir);
|
|
31
33
|
const startedAt = Date.now();
|
|
34
|
+
const fixedTime = resolveFixedTime(config, startedAt);
|
|
35
|
+
config.fixedTime = fixedTime;
|
|
36
|
+
const localePlan = buildLocalePlan(config);
|
|
32
37
|
|
|
33
38
|
await assertSourceDir(sourceDir, config);
|
|
34
|
-
const outputDir = await
|
|
39
|
+
const [outputDir, routeSources] = await Promise.all([
|
|
40
|
+
resolveOutputDir({ sourceDir, config, log }),
|
|
41
|
+
loadRouteSeeds(config),
|
|
42
|
+
]);
|
|
35
43
|
const previousManifest =
|
|
36
44
|
config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
|
|
37
|
-
const routeSources = await loadRouteSeeds(config);
|
|
38
45
|
for (const source of routeSources.sources) {
|
|
39
46
|
const skipped = source.skipped > 0 ? `, ${source.skipped} skipped` : '';
|
|
40
47
|
log.info(`Route source ${source.source}: ${source.routes} route(s)${skipped}`);
|
|
@@ -44,14 +51,21 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
44
51
|
log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const browserPromise = launchBrowser(config, log);
|
|
55
|
+
let server;
|
|
56
|
+
try {
|
|
57
|
+
server = config.serveCmd
|
|
58
|
+
? await startCommandServer({
|
|
59
|
+
command: config.serveCmd,
|
|
60
|
+
url: config.url,
|
|
61
|
+
timeout: config.serveCmdTimeout,
|
|
62
|
+
shutdownTimeout: config.shutdownTimeout,
|
|
63
|
+
})
|
|
64
|
+
: await startStaticServer({ dir: outputDir, base: config.base });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
browserPromise.then(({ browser }) => browser.close()).catch(() => {});
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
const report = {
|
|
57
71
|
routes: [],
|
|
@@ -67,6 +81,17 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
67
81
|
notFoundRoutes: [],
|
|
68
82
|
routeSources: routeSources.sources,
|
|
69
83
|
routeDetails: [],
|
|
84
|
+
clock: fixedTime === null ? null : { fixedTime, timezone: config.timezone ?? null },
|
|
85
|
+
locales: localePlan
|
|
86
|
+
? { mode: localePlan.mode, default: localePlan.default, active: localePlan.active ?? null }
|
|
87
|
+
: null,
|
|
88
|
+
seoLocale: config.seoLocale
|
|
89
|
+
? {
|
|
90
|
+
code: config.seoLocale.code ?? config.locales?.active ?? null,
|
|
91
|
+
source: config.seoLocale.source,
|
|
92
|
+
inject: config.seoLocale.inject,
|
|
93
|
+
}
|
|
94
|
+
: null,
|
|
70
95
|
cspSkipped: [],
|
|
71
96
|
traces: [],
|
|
72
97
|
verification: null,
|
|
@@ -77,16 +102,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
77
102
|
truncated: null,
|
|
78
103
|
ok: false,
|
|
79
104
|
durationMs: 0,
|
|
105
|
+
timings: { render: 0, write: 0, artifacts: 0, verify: 0, audit: 0 },
|
|
80
106
|
};
|
|
81
107
|
const manifestEntries = [];
|
|
82
108
|
const state = { warnedBuildOriginMetadata: false, warnedCriticalCssSkipped: false };
|
|
83
109
|
let browser = null;
|
|
84
110
|
|
|
85
111
|
try {
|
|
86
|
-
const launched = await
|
|
112
|
+
const launched = await browserPromise;
|
|
87
113
|
browser = launched.browser;
|
|
88
114
|
log.info(`Browser: ${launched.source}`);
|
|
115
|
+
const renderCaches = { inlineCss: new Map(), criticalCss: new Map() };
|
|
89
116
|
|
|
117
|
+
let phaseStartedAt = Date.now();
|
|
90
118
|
const rendered = await renderAllRoutes({
|
|
91
119
|
browser,
|
|
92
120
|
server,
|
|
@@ -95,7 +123,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
95
123
|
report,
|
|
96
124
|
outputDir,
|
|
97
125
|
sourceSeeds: routeSources.routes,
|
|
126
|
+
caches: renderCaches,
|
|
127
|
+
localePlan,
|
|
98
128
|
});
|
|
129
|
+
report.timings.render = Date.now() - phaseStartedAt;
|
|
130
|
+
const { rendered: renderedRoutes, routeLocales } = rendered;
|
|
131
|
+
if (localePlan) {
|
|
132
|
+
const renderedByLocale = {};
|
|
133
|
+
for (const route of report.routes) {
|
|
134
|
+
const code = routeLocales.get(route)?.locale?.code ?? localePlan.default;
|
|
135
|
+
renderedByLocale[code] = (renderedByLocale[code] ?? 0) + 1;
|
|
136
|
+
}
|
|
137
|
+
report.locales.rendered = renderedByLocale;
|
|
138
|
+
}
|
|
99
139
|
if (report.routes.length === 0) {
|
|
100
140
|
report.errors.push({ route: null, message: 'no routes were rendered' });
|
|
101
141
|
log.error('No routes were rendered; check include, exclude and sourceDir');
|
|
@@ -110,19 +150,32 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
110
150
|
);
|
|
111
151
|
}
|
|
112
152
|
|
|
153
|
+
phaseStartedAt = Date.now();
|
|
113
154
|
const verifiable = await writeAllRoutes({
|
|
114
|
-
rendered,
|
|
155
|
+
rendered: renderedRoutes,
|
|
156
|
+
routeLocales,
|
|
157
|
+
localePlan,
|
|
115
158
|
outputDir,
|
|
116
159
|
config,
|
|
117
160
|
log,
|
|
118
161
|
report,
|
|
119
162
|
state,
|
|
120
163
|
manifestEntries,
|
|
164
|
+
caches: renderCaches,
|
|
121
165
|
});
|
|
166
|
+
report.timings.write = Date.now() - phaseStartedAt;
|
|
167
|
+
const localeScripts = report.files
|
|
168
|
+
.filter((file) => file.file?.startsWith('snappy-seo-locale-'))
|
|
169
|
+
.map((file) => file.file);
|
|
170
|
+
if (report.seoLocale) {
|
|
171
|
+
report.seoLocale.script = localeScripts[0] ?? null;
|
|
172
|
+
report.seoLocale.scripts = [...new Set(localeScripts)];
|
|
173
|
+
}
|
|
122
174
|
logFileSummary(report, log);
|
|
123
175
|
logNotFoundNotice(report, config, log);
|
|
124
176
|
logDuplicateTitles(report, log);
|
|
125
177
|
|
|
178
|
+
phaseStartedAt = Date.now();
|
|
126
179
|
if (config.sitemap) {
|
|
127
180
|
try {
|
|
128
181
|
await writeSitemap({ outputDir, config, log, report });
|
|
@@ -155,6 +208,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
155
208
|
log.error(`preload manifest write failed: ${error.message}`);
|
|
156
209
|
}
|
|
157
210
|
}
|
|
211
|
+
report.timings.artifacts = Date.now() - phaseStartedAt;
|
|
158
212
|
|
|
159
213
|
const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
|
|
160
214
|
if (canVerify && config.serveCmd) {
|
|
@@ -164,13 +218,13 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
164
218
|
}
|
|
165
219
|
// With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
|
|
166
220
|
// and the bundle are the same as the last time it was verified.
|
|
221
|
+
const fileStatus = new Map(
|
|
222
|
+
report.files.map((file) => [`${file.route ?? ''}|${file.file ?? ''}`, file.status]),
|
|
223
|
+
);
|
|
167
224
|
const toVerify = config.verifyChangedOnly
|
|
168
225
|
? verifiable.filter((route) => {
|
|
169
226
|
const file = routeToFile(route, config);
|
|
170
|
-
|
|
171
|
-
(candidate) => candidate.route === route && candidate.file === file,
|
|
172
|
-
);
|
|
173
|
-
return entry?.status !== 'unchanged';
|
|
227
|
+
return fileStatus.get(`${route}|${file}`) !== 'unchanged';
|
|
174
228
|
})
|
|
175
229
|
: verifiable;
|
|
176
230
|
const skipped = verifiable.length - toVerify.length;
|
|
@@ -178,14 +232,18 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
178
232
|
log.info(`Verification skipped for ${skipped} unchanged route(s), as verifyChangedOnly asks`);
|
|
179
233
|
}
|
|
180
234
|
if (canVerify && !config.serveCmd && toVerify.length > 0) {
|
|
235
|
+
phaseStartedAt = Date.now();
|
|
181
236
|
report.verification = await verifyRoutes({
|
|
182
237
|
browser,
|
|
183
238
|
origin: server.origin,
|
|
184
239
|
routes: toVerify.sort(),
|
|
185
240
|
config,
|
|
186
241
|
outputDir,
|
|
242
|
+
routeLocales,
|
|
187
243
|
});
|
|
244
|
+
report.timings.verify = Date.now() - phaseStartedAt;
|
|
188
245
|
report.verification.skipped = skipped;
|
|
246
|
+
report.verification.policySkipped = report.verification.skippedRoutes.length;
|
|
189
247
|
logVerification(report.verification, config, log);
|
|
190
248
|
mergeVerification(report);
|
|
191
249
|
}
|
|
@@ -224,6 +282,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
224
282
|
|
|
225
283
|
report.durationMs = Date.now() - startedAt;
|
|
226
284
|
if (config.audit) {
|
|
285
|
+
const auditStartedAt = Date.now();
|
|
227
286
|
try {
|
|
228
287
|
report.audit = await auditRun({ report, config, outputDir });
|
|
229
288
|
logAudit(report.audit, log);
|
|
@@ -231,8 +290,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
231
290
|
report.errors.push({ route: null, message: `audit failed: ${error.message}` });
|
|
232
291
|
log.error(`audit failed: ${error.message}`);
|
|
233
292
|
}
|
|
293
|
+
report.timings.audit = Date.now() - auditStartedAt;
|
|
234
294
|
}
|
|
235
295
|
const reRendered = report.verification?.modes['re-rendered'] ?? 0;
|
|
296
|
+
const allowedRerender = report.verification?.allowedRerender ?? 0;
|
|
297
|
+
const notAllowedRerender = Math.max(0, reRendered - allowedRerender);
|
|
236
298
|
const undetected = report.verification
|
|
237
299
|
? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
|
|
238
300
|
: 0;
|
|
@@ -247,7 +309,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
247
309
|
!(config.failOnHydrationError && report.verification && !report.verification.ok) &&
|
|
248
310
|
!(config.failOnVerificationError && report.verification?.errors.length > 0) &&
|
|
249
311
|
!(config.failOnUndetected && undetected > 0) &&
|
|
250
|
-
!(config.failOnRerender &&
|
|
312
|
+
!(config.failOnRerender && notAllowedRerender > 0);
|
|
251
313
|
try {
|
|
252
314
|
report.reportFile = await writeReport({ report, config, log });
|
|
253
315
|
} catch (error) {
|
|
@@ -300,12 +362,16 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
300
362
|
|
|
301
363
|
// Adds the verification result to each route record for the JSON report.
|
|
302
364
|
function mergeVerification(report) {
|
|
365
|
+
const details = new Map(report.routeDetails.map((record) => [record.route, record]));
|
|
303
366
|
for (const entry of report.verification?.routes ?? []) {
|
|
304
|
-
const detail =
|
|
367
|
+
const detail = details.get(entry.route);
|
|
305
368
|
if (!detail) continue;
|
|
306
369
|
detail.mode = entry.mode;
|
|
307
370
|
detail.framework = entry.framework ?? null;
|
|
308
371
|
detail.hydrationErrors = entry.hydrationErrors;
|
|
372
|
+
detail.ignoredHydrationErrors = entry.ignoredHydrationErrors ?? [];
|
|
373
|
+
detail.hydrationPolicy = entry.hydrationPolicy ?? null;
|
|
374
|
+
detail.hydrationAllowed = entry.allowed === true;
|
|
309
375
|
detail.pageErrors = [...new Set([...(detail.pageErrors ?? []), ...entry.pageErrors])];
|
|
310
376
|
detail.consoleErrors = [...new Set([...(detail.consoleErrors ?? []), ...entry.consoleErrors])];
|
|
311
377
|
detail.verificationStatus = entry.status;
|
|
@@ -372,8 +438,10 @@ function logFileSummary(report, log) {
|
|
|
372
438
|
// were reported but not enforced.
|
|
373
439
|
function logVerification(verification, config, log) {
|
|
374
440
|
const modes = verification.modes ?? {};
|
|
441
|
+
const allowed = verification.allowed ?? 0;
|
|
442
|
+
const policySkipped = verification.policySkipped ?? 0;
|
|
375
443
|
log.info(
|
|
376
|
-
`Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
|
|
444
|
+
`Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown${allowed > 0 ? `, ${allowed} allowed` : ''}${policySkipped > 0 ? `, ${policySkipped} skipped by policy` : ''}`,
|
|
377
445
|
);
|
|
378
446
|
const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
|
|
379
447
|
if (notDetected > 0) {
|
|
@@ -415,7 +483,9 @@ function logVerification(verification, config, log) {
|
|
|
415
483
|
}
|
|
416
484
|
}
|
|
417
485
|
|
|
418
|
-
const reRendered = verification.routes.filter(
|
|
486
|
+
const reRendered = verification.routes.filter(
|
|
487
|
+
(entry) => entry.mode === 're-rendered' && !entry.allowed,
|
|
488
|
+
);
|
|
419
489
|
if (reRendered.length > 0) {
|
|
420
490
|
const sample = reRendered
|
|
421
491
|
.slice(0, 5)
|