snappy-prerender 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +122 -101
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +2 -1
- package/src/cli-init.js +205 -0
- package/src/cli.js +73 -168
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/config.js +129 -5
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +143 -30
- package/src/core/normalize.js +7 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/render.js +87 -5
- package/src/core/report.js +108 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/run.js +68 -113
- package/src/core/sitemap.js +90 -14
- package/src/core/verify.js +92 -48
- package/src/core/write.js +92 -7
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +52 -6
package/src/core/sitemap.js
CHANGED
|
@@ -15,7 +15,7 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
|
|
|
15
15
|
if (!siteUrl) return null;
|
|
16
16
|
const notFound = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const entries = [];
|
|
19
19
|
for (const file of files) {
|
|
20
20
|
if (!file.file.endsWith('.html') || file.noindex) continue;
|
|
21
21
|
if (file.route && notFound && normaliseRoute(file.route) === notFound) continue;
|
|
@@ -25,31 +25,40 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
|
|
|
25
25
|
trailingSlash: config.metadata?.trailingSlash,
|
|
26
26
|
})
|
|
27
27
|
: publicPath(file.file);
|
|
28
|
-
|
|
28
|
+
entries.push({
|
|
29
|
+
loc: `${siteUrl}${toPublicPath(path, config.base)}`,
|
|
30
|
+
alternates: normaliseAlternates(file.alternates ?? [], siteUrl, config.base),
|
|
31
|
+
images: config.imageSitemap ? normaliseImages(file.images ?? [], siteUrl, config.base) : [],
|
|
32
|
+
});
|
|
29
33
|
}
|
|
30
|
-
if (
|
|
34
|
+
if (entries.length === 0) return null;
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
33
37
|
const chunks = [];
|
|
34
|
-
for (let index = 0; index <
|
|
35
|
-
chunks.push(
|
|
38
|
+
for (let index = 0; index < entries.length; index += maxUrls) {
|
|
39
|
+
chunks.push(entries.slice(index, index + maxUrls));
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
if (chunks.length === 1) {
|
|
39
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
files: [{ name: 'sitemap.xml', contents: urlSet(chunks[0]) }],
|
|
45
|
+
routes: entries.length,
|
|
46
|
+
locs: entries.map((entry) => entry.loc),
|
|
47
|
+
};
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
const
|
|
50
|
+
const chunksWithNames = chunks.map((chunk, index) => {
|
|
43
51
|
const name = `sitemap-${index + 1}.xml`;
|
|
44
52
|
const loc = `${siteUrl}${toPublicPath(`/${name}`, config.base)}`;
|
|
45
53
|
return { name, contents: urlSet(chunk), loc };
|
|
46
54
|
});
|
|
47
55
|
return {
|
|
48
56
|
files: [
|
|
49
|
-
{ name: 'sitemap.xml', contents: sitemapIndex(
|
|
50
|
-
...
|
|
57
|
+
{ name: 'sitemap.xml', contents: sitemapIndex(chunksWithNames) },
|
|
58
|
+
...chunksWithNames.map(({ name, contents }) => ({ name, contents })),
|
|
51
59
|
],
|
|
52
|
-
routes:
|
|
60
|
+
routes: entries.length,
|
|
61
|
+
locs: entries.map((entry) => entry.loc),
|
|
53
62
|
};
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -75,9 +84,76 @@ export function withSitemapDirective(robots, sitemapUrl) {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
// Builds one urlset document.
|
|
78
|
-
function urlSet(
|
|
79
|
-
const
|
|
80
|
-
|
|
87
|
+
function urlSet(entries) {
|
|
88
|
+
const hasAlternates = entries.some((entry) => entry.alternates.length > 0);
|
|
89
|
+
const hasImages = entries.some((entry) => entry.images.length > 0);
|
|
90
|
+
const namespaces = [
|
|
91
|
+
'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
|
|
92
|
+
hasAlternates ? 'xmlns:xhtml="http://www.w3.org/1999/xhtml"' : null,
|
|
93
|
+
hasImages ? 'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"' : null,
|
|
94
|
+
]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ');
|
|
97
|
+
const urls = entries
|
|
98
|
+
.map((entry) => {
|
|
99
|
+
const lines = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
100
|
+
for (const alternate of entry.alternates) {
|
|
101
|
+
lines.push(
|
|
102
|
+
` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${escapeXml(alternate.href)}"/>`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
for (const image of entry.images) {
|
|
106
|
+
lines.push(
|
|
107
|
+
` <image:image>\n <image:loc>${escapeXml(image)}</image:loc>\n </image:image>`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return ` <url>\n${lines.join('\n')}\n </url>`;
|
|
111
|
+
})
|
|
112
|
+
.join('\n');
|
|
113
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ${namespaces}>\n${urls}\n</urlset>\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolves hreflang links and removes duplicates.
|
|
117
|
+
function normaliseAlternates(alternates, siteUrl, base) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const alternate of alternates) {
|
|
121
|
+
const href = absoluteSitemapUrl(alternate.href, siteUrl, base);
|
|
122
|
+
if (!href) continue;
|
|
123
|
+
const key = `${alternate.hreflang}|${href}`;
|
|
124
|
+
if (seen.has(key)) continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
result.push({ hreflang: alternate.hreflang, href });
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Resolves image URLs to absolute same-origin URLs.
|
|
132
|
+
function normaliseImages(images, siteUrl, base) {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const result = [];
|
|
135
|
+
for (const image of images) {
|
|
136
|
+
const absolute = absoluteSitemapUrl(image, siteUrl, base);
|
|
137
|
+
if (!absolute || new URL(absolute).origin !== new URL(siteUrl).origin) continue;
|
|
138
|
+
if (seen.has(absolute)) continue;
|
|
139
|
+
seen.add(absolute);
|
|
140
|
+
result.push(absolute);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolves a site-relative path against the public base, leaving absolute URLs alone.
|
|
146
|
+
function absoluteSitemapUrl(value, siteUrl, base) {
|
|
147
|
+
if (!value) return null;
|
|
148
|
+
const raw = String(value);
|
|
149
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
150
|
+
if (raw.startsWith('//')) return `https:${raw}`;
|
|
151
|
+
const basePrefix = base === '/' ? '' : base.slice(0, -1);
|
|
152
|
+
if (basePrefix && (raw === basePrefix || raw.startsWith(`${basePrefix}/`))) {
|
|
153
|
+
return `${siteUrl}${raw}`;
|
|
154
|
+
}
|
|
155
|
+
const path = raw.startsWith('/') ? raw : `/${raw}`;
|
|
156
|
+
return `${siteUrl}${basePrefix}${path}`;
|
|
81
157
|
}
|
|
82
158
|
|
|
83
159
|
// Builds the sitemap index that points at the numbered sitemaps.
|
package/src/core/verify.js
CHANGED
|
@@ -1,24 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { blockThirdPartyRequests } from './blocking.js';
|
|
2
4
|
import { DEFAULTS } from './config.js';
|
|
5
|
+
import { frameworkFromMarkers, isHydrationError } from './frameworks.js';
|
|
6
|
+
import { compareContainers } from './hydration-diff.js';
|
|
7
|
+
import { routeToFile } from './output.js';
|
|
3
8
|
import { isPageEmpty } from './page-content.js';
|
|
4
|
-
import {
|
|
9
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
10
|
+
import { normaliseRoute, toServerUrl } from './routes.js';
|
|
5
11
|
import { mapLimit } from './scheduler.js';
|
|
6
12
|
import { settlePage, trackNetwork } from './settle.js';
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
/hydrat/i,
|
|
10
|
-
/text content did not match/i,
|
|
11
|
-
/prop [`"].+?[`"] did not match/i,
|
|
12
|
-
/server-rendered HTML/i,
|
|
13
|
-
// React 16 and 17 phrase their mismatch warnings differently and never use the
|
|
14
|
-
// minified codes, which only exist from React 18 on.
|
|
15
|
-
/did not expect server HTML/i,
|
|
16
|
-
/expected server HTML to contain/i,
|
|
17
|
-
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
18
|
-
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
// Containers used by the common React setups, so the probe can find the prerendered
|
|
14
|
+
// Containers used by the common setups, so the probe can find the prerendered
|
|
22
15
|
// subtree without configuration.
|
|
23
16
|
const CONTAINER_SELECTOR = '#root, #app, #__next';
|
|
24
17
|
|
|
@@ -48,19 +41,9 @@ function bootProbe() {
|
|
|
48
41
|
});
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
/**
|
|
52
|
-
* Classifies a console or page error as a React hydration failure. Exported so the
|
|
53
|
-
* pattern set is testable without a browser. Patterns stay narrow on purpose: a
|
|
54
|
-
* false positive fails a user's build, so unrelated 4xx React codes and generic
|
|
55
|
-
* "does not match" messages are deliberately not treated as hydration errors.
|
|
56
|
-
*/
|
|
57
|
-
export function isHydrationError(message) {
|
|
58
|
-
return HYDRATION_PATTERNS.some((pattern) => pattern.test(message));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
44
|
/**
|
|
62
45
|
* Classifies how a prerendered page booted. `hydrated` means the app adopted the
|
|
63
|
-
* markup, `re-rendered` means it discarded it, `undetected` means no
|
|
46
|
+
* markup, `re-rendered` means it discarded it, `undetected` means no framework root was
|
|
64
47
|
* found on the container, and `unknown` means the prerendered child was never seen.
|
|
65
48
|
*/
|
|
66
49
|
export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
@@ -74,16 +57,22 @@ export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
|
74
57
|
* hydration errors plus how each route booted. This is the pass that catches markup
|
|
75
58
|
* that only breaks once the app re-attaches, and apps that never attach to it at all.
|
|
76
59
|
*/
|
|
77
|
-
export async function verifyRoutes({ browser, origin, routes, config }) {
|
|
60
|
+
export async function verifyRoutes({ browser, origin, routes, config, outputDir = null }) {
|
|
78
61
|
const { results, errors } = await mapLimit(routes, config.concurrency, (route) =>
|
|
79
|
-
verifyRoute({ browser, origin, route, config }),
|
|
62
|
+
verifyRoute({ browser, origin, route, config: resolveRouteConfig(config, route), outputDir }),
|
|
80
63
|
);
|
|
81
64
|
const verified = results.filter(Boolean);
|
|
82
65
|
const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
|
|
83
|
-
|
|
66
|
+
const frameworks = {};
|
|
67
|
+
for (const entry of verified) {
|
|
68
|
+
modes[entry.mode] += 1;
|
|
69
|
+
const name = entry.framework ?? 'unknown';
|
|
70
|
+
frameworks[name] = (frameworks[name] ?? 0) + 1;
|
|
71
|
+
}
|
|
84
72
|
return {
|
|
85
73
|
routes: verified,
|
|
86
74
|
modes,
|
|
75
|
+
frameworks,
|
|
87
76
|
ok: verified.every((entry) => entry.ok) && errors.length === 0,
|
|
88
77
|
errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
|
|
89
78
|
};
|
|
@@ -93,7 +82,7 @@ export async function verifyRoutes({ browser, origin, routes, config }) {
|
|
|
93
82
|
* Verifies one route against the same request rules as the render pass, so what is
|
|
94
83
|
* verified matches what was rendered.
|
|
95
84
|
*/
|
|
96
|
-
async function verifyRoute({ browser, origin, route, config }) {
|
|
85
|
+
async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
97
86
|
const context = await browser.newContext({
|
|
98
87
|
viewport: config.viewport,
|
|
99
88
|
storageState: config.storageState ?? undefined,
|
|
@@ -104,18 +93,12 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
104
93
|
serviceWorkers: 'block',
|
|
105
94
|
});
|
|
106
95
|
const page = await context.newPage();
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const pageErrors = [];
|
|
110
|
-
// Hydration messages arrive through either channel, so both are classified the same way.
|
|
111
|
-
const collect = (message, target) => {
|
|
112
|
-
if (isHydrationError(message)) hydrationErrors.push(message);
|
|
113
|
-
else target.push(message);
|
|
114
|
-
};
|
|
96
|
+
const consoleMessages = [];
|
|
97
|
+
const pageMessages = [];
|
|
115
98
|
page.on('console', (message) => {
|
|
116
|
-
if (message.type() === 'error')
|
|
99
|
+
if (message.type() === 'error') consoleMessages.push(message.text());
|
|
117
100
|
});
|
|
118
|
-
page.on('pageerror', (error) =>
|
|
101
|
+
page.on('pageerror', (error) => pageMessages.push(error.message));
|
|
119
102
|
|
|
120
103
|
const tracker = trackNetwork(page);
|
|
121
104
|
try {
|
|
@@ -130,29 +113,90 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
130
113
|
const status = response?.status() ?? null;
|
|
131
114
|
await settlePage(page, config, tracker);
|
|
132
115
|
const empty = await isPageEmpty(page);
|
|
116
|
+
const statusCode = await page.evaluate(() => {
|
|
117
|
+
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
118
|
+
if (!marker) return null;
|
|
119
|
+
const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
|
|
120
|
+
return Number.isFinite(value) ? value : null;
|
|
121
|
+
});
|
|
122
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
123
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
124
|
+
const notFound = statusCode === 404 || isNotFoundRoute;
|
|
133
125
|
|
|
134
126
|
const boot = await page.evaluate((selector) => {
|
|
135
127
|
const probe = window.__snappyBoot ?? {};
|
|
136
128
|
const container =
|
|
137
129
|
document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
|
|
138
|
-
const
|
|
139
|
-
?
|
|
140
|
-
(
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
const markers = container
|
|
131
|
+
? {
|
|
132
|
+
react: Object.keys(container).some(
|
|
133
|
+
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
134
|
+
),
|
|
135
|
+
vue: Boolean(
|
|
136
|
+
container.__vue_app__ ||
|
|
137
|
+
container.__vueParentComponent ||
|
|
138
|
+
container._vnode ||
|
|
139
|
+
container.__vue__,
|
|
140
|
+
),
|
|
141
|
+
preact: Boolean(
|
|
142
|
+
container.__k ||
|
|
143
|
+
container._children ||
|
|
144
|
+
container.firstElementChild?.__k ||
|
|
145
|
+
container.firstElementChild?._children,
|
|
146
|
+
),
|
|
147
|
+
svelte: Boolean(
|
|
148
|
+
container.__svelte_meta || container.querySelector?.('[data-svelte-h]'),
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
: {};
|
|
143
152
|
const child = probe.prerenderedChild ?? null;
|
|
144
|
-
return {
|
|
153
|
+
return { markers, markupPreserved: child ? child.isConnected : null };
|
|
145
154
|
}, CONTAINER_SELECTOR);
|
|
155
|
+
const detected = frameworkFromMarkers(boot.markers, config.framework);
|
|
156
|
+
// Classify only after the framework is known, so an explicit `framework` choice and
|
|
157
|
+
// the detected framework select the matching message patterns.
|
|
158
|
+
const hydrationErrors = [];
|
|
159
|
+
const consoleErrors = [];
|
|
160
|
+
const pageErrors = [];
|
|
161
|
+
for (const message of consoleMessages) {
|
|
162
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
163
|
+
else consoleErrors.push(message);
|
|
164
|
+
}
|
|
165
|
+
for (const message of pageMessages) {
|
|
166
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
167
|
+
else pageErrors.push(message);
|
|
168
|
+
}
|
|
169
|
+
const mode = classifyBoot({
|
|
170
|
+
markupPreserved: boot.markupPreserved,
|
|
171
|
+
frameworkMounted: detected.mounted,
|
|
172
|
+
});
|
|
173
|
+
let firstMismatch = null;
|
|
174
|
+
if (outputDir && (hydrationErrors.length > 0 || mode === 're-rendered')) {
|
|
175
|
+
const liveHtml = await page.evaluate((selector) => {
|
|
176
|
+
const container = document.querySelector(selector) ?? document.body;
|
|
177
|
+
return container?.outerHTML ?? null;
|
|
178
|
+
}, CONTAINER_SELECTOR);
|
|
179
|
+
const file = path.join(outputDir, routeToFile(route, config));
|
|
180
|
+
const expectedHtml = await fs.readFile(file, 'utf8').catch(() => null);
|
|
181
|
+
if (liveHtml && expectedHtml) firstMismatch = compareContainers(expectedHtml, liveHtml);
|
|
182
|
+
}
|
|
146
183
|
|
|
147
184
|
return {
|
|
148
185
|
route,
|
|
149
186
|
status,
|
|
187
|
+
statusCode,
|
|
188
|
+
notFound,
|
|
189
|
+
framework: detected.name,
|
|
150
190
|
empty,
|
|
151
191
|
hydrationErrors,
|
|
152
192
|
consoleErrors,
|
|
153
193
|
pageErrors,
|
|
154
|
-
ok:
|
|
155
|
-
|
|
194
|
+
ok:
|
|
195
|
+
hydrationErrors.length === 0 &&
|
|
196
|
+
(status === null || status < 400 || (isNotFoundRoute && status === 404)) &&
|
|
197
|
+
!empty,
|
|
198
|
+
mode,
|
|
199
|
+
firstMismatch,
|
|
156
200
|
markupPreserved: boot.markupPreserved,
|
|
157
201
|
};
|
|
158
202
|
} finally {
|
package/src/core/write.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { findBuildOriginUrls } from './build-origin.js';
|
|
3
|
+
import { applyCspHashes } from './csp.js';
|
|
3
4
|
import { inlineCriticalCss } from './inline-css.js';
|
|
4
5
|
import { minifyHtml } from './minify.js';
|
|
5
6
|
import { normaliseHtml } from './normalize.js';
|
|
6
7
|
import {
|
|
7
8
|
findOutputCollisions,
|
|
9
|
+
routeToFile,
|
|
8
10
|
routeToScreenshotFile,
|
|
9
11
|
writeBufferIfChanged,
|
|
10
12
|
writeFileIfChanged,
|
|
11
13
|
writeRouteHtml,
|
|
12
14
|
} from './output.js';
|
|
15
|
+
import { collectPageMeta } from './page-meta.js';
|
|
16
|
+
import { collectScriptSources, injectPreloadLinks } from './preload-links.js';
|
|
13
17
|
import { collectPageResources } from './preload-manifest.js';
|
|
18
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
14
19
|
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
15
20
|
|
|
16
21
|
/**
|
|
@@ -36,16 +41,43 @@ export async function writeAllRoutes({
|
|
|
36
41
|
for (const route of sorted) {
|
|
37
42
|
if (conflicted.has(route)) continue;
|
|
38
43
|
try {
|
|
44
|
+
const routeConfig = resolveRouteConfig(config, route);
|
|
45
|
+
const result = rendered.get(route);
|
|
39
46
|
const { file, resources } = await writeRouteOutput({
|
|
40
47
|
route,
|
|
41
|
-
result
|
|
48
|
+
result,
|
|
42
49
|
outputDir,
|
|
43
|
-
config,
|
|
50
|
+
config: routeConfig,
|
|
44
51
|
log,
|
|
45
52
|
state,
|
|
46
53
|
report,
|
|
47
54
|
});
|
|
48
55
|
report.files.push(file);
|
|
56
|
+
report.routeDetails.push({
|
|
57
|
+
route,
|
|
58
|
+
order: result.order ?? null,
|
|
59
|
+
file: file.file,
|
|
60
|
+
status: file.status,
|
|
61
|
+
bytes: file.bytes,
|
|
62
|
+
depth: result.depth ?? null,
|
|
63
|
+
attempts: result.attempts ?? 1,
|
|
64
|
+
httpStatus: result.httpStatus ?? null,
|
|
65
|
+
finalUrl: result.finalUrl ?? null,
|
|
66
|
+
empty: result.empty === true,
|
|
67
|
+
notFound: file.notFound === true,
|
|
68
|
+
noindex: file.noindex === true,
|
|
69
|
+
meta: file.meta ?? null,
|
|
70
|
+
images: file.images ?? [],
|
|
71
|
+
alternates: file.alternates ?? [],
|
|
72
|
+
mode: null,
|
|
73
|
+
framework: null,
|
|
74
|
+
hydrationErrors: [],
|
|
75
|
+
pageErrors: result.pageErrors ?? [],
|
|
76
|
+
consoleErrors: result.consoleErrors ?? [],
|
|
77
|
+
failedRequests: result.failedRequests ?? [],
|
|
78
|
+
firstMismatch: null,
|
|
79
|
+
buildOriginUrls: file.buildOriginUrls ?? [],
|
|
80
|
+
});
|
|
49
81
|
if (resources) manifestEntries.push({ route, ...resources });
|
|
50
82
|
if (file.hash) {
|
|
51
83
|
const group = hashes.get(file.hash) ?? [];
|
|
@@ -205,6 +237,53 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
205
237
|
if (config.minifyHtml) {
|
|
206
238
|
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
207
239
|
}
|
|
240
|
+
const preTransformScripts = config.transformHtml ? collectScriptSources(output) : new Set();
|
|
241
|
+
if (config.transformHtml) {
|
|
242
|
+
const transformed = await config.transformHtml(output, {
|
|
243
|
+
route,
|
|
244
|
+
file: routeToFile(route, config),
|
|
245
|
+
stats,
|
|
246
|
+
config,
|
|
247
|
+
});
|
|
248
|
+
if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
|
|
249
|
+
throw new TypeError('transformHtml must return a string, null or undefined');
|
|
250
|
+
}
|
|
251
|
+
output = transformed ?? output;
|
|
252
|
+
}
|
|
253
|
+
if (config.preloadLinks) {
|
|
254
|
+
const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
|
|
255
|
+
const skipScripts = new Set(
|
|
256
|
+
[...preTransformScripts].map((src) => {
|
|
257
|
+
if (!src.startsWith('/')) return src;
|
|
258
|
+
const appRelative =
|
|
259
|
+
basePrefix && (src === basePrefix || src.startsWith(`${basePrefix}/`))
|
|
260
|
+
? src.slice(basePrefix.length)
|
|
261
|
+
: src;
|
|
262
|
+
return toPublicPath(appRelative, config.base);
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
output = injectPreloadLinks(output, {
|
|
266
|
+
scripts: config.removeScriptTags ? [] : [...result.collector.scripts],
|
|
267
|
+
fonts: result.collector.fonts,
|
|
268
|
+
lcpImage: result.lcpImage,
|
|
269
|
+
base: config.base,
|
|
270
|
+
ignoreForPreload: config.ignoreForPreload,
|
|
271
|
+
skipScripts,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (config.csp === 'hash') {
|
|
275
|
+
const policy = applyCspHashes(output);
|
|
276
|
+
if (policy.existingPolicy) {
|
|
277
|
+
log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
|
|
278
|
+
report.cspSkipped.push(route);
|
|
279
|
+
} else {
|
|
280
|
+
output = policy.html;
|
|
281
|
+
log.debug(
|
|
282
|
+
` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const meta = collectPageMeta(output);
|
|
208
287
|
warnOnBuildOriginMetadata({
|
|
209
288
|
origins: stats.metadataOrigins,
|
|
210
289
|
removed: stats.removedMetadata,
|
|
@@ -212,11 +291,11 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
212
291
|
log,
|
|
213
292
|
state,
|
|
214
293
|
});
|
|
215
|
-
if (
|
|
294
|
+
if (meta.titleCount > 1) report.duplicateTitles.push(route);
|
|
295
|
+
const buildOrigins = findBuildOriginUrls(output);
|
|
216
296
|
if (config.failOnBuildOriginMetadata) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const message = `metadata still points at the build server: ${origins.join(', ')}`;
|
|
297
|
+
if (buildOrigins.length > 0) {
|
|
298
|
+
const message = `metadata still points at the build server: ${buildOrigins.join(', ')}`;
|
|
220
299
|
report.errors.push({ route, message });
|
|
221
300
|
log.error(` ${route}: ${message}`);
|
|
222
301
|
}
|
|
@@ -233,7 +312,13 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
233
312
|
hash: createHash('sha256')
|
|
234
313
|
.update(result.html ?? output)
|
|
235
314
|
.digest('hex'),
|
|
236
|
-
noindex:
|
|
315
|
+
noindex: meta.noindex,
|
|
316
|
+
notFound: result.notFound === true,
|
|
317
|
+
meta,
|
|
318
|
+
images: meta.images,
|
|
319
|
+
alternates: meta.alternates,
|
|
320
|
+
inlineStyleAttributes: meta.inlineStyleAttributes,
|
|
321
|
+
buildOriginUrls: buildOrigins,
|
|
237
322
|
},
|
|
238
323
|
resources,
|
|
239
324
|
};
|
package/src/head/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export function Head(props) {
|
|
|
26
26
|
sequenceRef.current = sequence;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const entries = buildHeadEntries(props, readMetadataDefaults());
|
|
29
|
+
const entries = buildHeadEntries(props, readMetadataDefaults(), idRef.current);
|
|
30
30
|
|
|
31
31
|
// A layout effect so a route change updates the head before the browser paints, and a
|
|
32
32
|
// plain effect on the server, where layout effects never run.
|
|
@@ -45,7 +45,7 @@ export function Head(props) {
|
|
|
45
45
|
*/
|
|
46
46
|
export function setHead(id, props) {
|
|
47
47
|
sequence += 1;
|
|
48
|
-
registerHead(id, sequence, buildHeadEntries(props, readMetadataDefaults()));
|
|
48
|
+
registerHead(id, sequence, buildHeadEntries(props, readMetadataDefaults(), id));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
package/src/head/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ const CREATED_ATTRIBUTE = 'data-snappy-head-created';
|
|
|
5
5
|
const stack = [];
|
|
6
6
|
const snapshots = new WeakMap();
|
|
7
7
|
const createdElements = new WeakSet();
|
|
8
|
+
const htmlOriginals = new Map();
|
|
8
9
|
const originals = readRecordedOriginals();
|
|
9
10
|
publishOriginals();
|
|
10
11
|
|
|
@@ -56,7 +57,9 @@ function applyMerged() {
|
|
|
56
57
|
if (typeof document === 'undefined' || !document.head) return;
|
|
57
58
|
const head = document.head;
|
|
58
59
|
const merged = mergeHeadEntries(stack);
|
|
59
|
-
const
|
|
60
|
+
const headEntries = merged.filter((entry) => entry.kind !== 'html');
|
|
61
|
+
const htmlEntries = merged.filter((entry) => entry.kind === 'html');
|
|
62
|
+
const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
|
|
60
63
|
|
|
61
64
|
for (const [key, entry] of desired) {
|
|
62
65
|
const matches = findTags(head, key);
|
|
@@ -74,7 +77,27 @@ function applyMerged() {
|
|
|
74
77
|
if (!desired.has(key)) releaseTag(element, key);
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
applyHtmlAttributes(htmlEntries);
|
|
81
|
+
orderTags(head, headEntries);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Applies lang/dir to the document element and restores the original value when no Head
|
|
85
|
+
// wants the attribute any more.
|
|
86
|
+
function applyHtmlAttributes(entries) {
|
|
87
|
+
if (typeof document === 'undefined' || !document.documentElement) return;
|
|
88
|
+
const root = document.documentElement;
|
|
89
|
+
const desired = new Map(entries.map((entry) => [entry.key, entry.value]));
|
|
90
|
+
for (const [key, value] of desired) {
|
|
91
|
+
if (!htmlOriginals.has(key)) htmlOriginals.set(key, root.getAttribute(key));
|
|
92
|
+
root.setAttribute(key, value);
|
|
93
|
+
}
|
|
94
|
+
for (const key of [...htmlOriginals.keys()]) {
|
|
95
|
+
if (desired.has(key)) continue;
|
|
96
|
+
const previous = htmlOriginals.get(key);
|
|
97
|
+
if (previous === null) root.removeAttribute(key);
|
|
98
|
+
else root.setAttribute(key, previous);
|
|
99
|
+
htmlOriginals.delete(key);
|
|
100
|
+
}
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
/**
|
|
@@ -87,6 +110,9 @@ function findTags(head, key) {
|
|
|
87
110
|
const title = head.querySelector('title');
|
|
88
111
|
return title ? [title] : [];
|
|
89
112
|
}
|
|
113
|
+
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
114
|
+
if (owned) return [owned];
|
|
115
|
+
if (key.startsWith('link:')) return findLinkTags(head, key.slice(5));
|
|
90
116
|
const [kind, attribute, ...rest] = key.split(':');
|
|
91
117
|
const value = rest.join(':');
|
|
92
118
|
if (
|
|
@@ -95,18 +121,32 @@ function findTags(head, key) {
|
|
|
95
121
|
) {
|
|
96
122
|
return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
|
|
97
123
|
}
|
|
98
|
-
|
|
99
|
-
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
100
|
-
return owned ? [owned] : [];
|
|
124
|
+
return [];
|
|
101
125
|
}
|
|
102
126
|
|
|
103
|
-
// Matches links
|
|
104
|
-
//
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
// Matches links that a previous run or the app itself wrote. Head-owned links carry the
|
|
128
|
+
// owner attribute and are found above; this handles adoption on the first run.
|
|
129
|
+
function findLinkTags(head, spec) {
|
|
130
|
+
const links = [...head.querySelectorAll('link')];
|
|
131
|
+
if (spec.startsWith('rel:')) {
|
|
132
|
+
const [rel, hreflang, href] = spec.slice(4).split('|');
|
|
133
|
+
return links.filter(
|
|
134
|
+
(link) =>
|
|
135
|
+
link.getAttribute('rel') === rel &&
|
|
136
|
+
(hreflang === undefined || link.getAttribute('hreflang') === hreflang) &&
|
|
137
|
+
(href === undefined || link.getAttribute('href') === href),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!spec.includes('=')) return [];
|
|
141
|
+
const attrs = Object.fromEntries(
|
|
142
|
+
spec.split('|').map((pair) => {
|
|
143
|
+
const index = pair.indexOf('=');
|
|
144
|
+
return [pair.slice(0, index), pair.slice(index + 1)];
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
return links.filter((link) =>
|
|
148
|
+
Object.entries(attrs).every(([name, value]) => link.getAttribute(name) === value),
|
|
149
|
+
);
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
function createTag(head, entry) {
|
|
@@ -140,6 +180,7 @@ function writeTag(element, entry, key) {
|
|
|
140
180
|
if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
|
|
141
181
|
}
|
|
142
182
|
for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
|
|
183
|
+
if (entry.kind === 'script') element.textContent = entry.text;
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
// Remembers what a tag looked like before the head layer first touched it, so an app-owned
|
|
@@ -155,7 +196,7 @@ function snapshotTag(element, key) {
|
|
|
155
196
|
}
|
|
156
197
|
const snapshot = {
|
|
157
198
|
attrs: Object.fromEntries(attrs),
|
|
158
|
-
text: element.tagName === 'TITLE' ? element.textContent : null,
|
|
199
|
+
text: element.tagName === 'TITLE' || element.tagName === 'SCRIPT' ? element.textContent : null,
|
|
159
200
|
};
|
|
160
201
|
snapshots.set(element, snapshot);
|
|
161
202
|
if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
|