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,234 @@
|
|
|
1
|
+
import { matchesPattern } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the SEO locale recipe for one route. The code comes from the route locale in
|
|
5
|
+
* path mode, from the active locale in single mode, or from seoLocale.code standalone.
|
|
6
|
+
* @param {object} config
|
|
7
|
+
* @param {object | null} locale
|
|
8
|
+
* @param {string} route
|
|
9
|
+
* @returns {object | null}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSeoLocale(config, locale, route) {
|
|
12
|
+
const seo = config.seoLocale;
|
|
13
|
+
if (!seo) return null;
|
|
14
|
+
if (!(seo.routes ?? []).some((pattern) => matchesPattern(route, pattern))) return null;
|
|
15
|
+
const code =
|
|
16
|
+
config.locales?.mode === 'path'
|
|
17
|
+
? locale?.code
|
|
18
|
+
: (config.locales?.active ?? seo.code ?? locale?.code);
|
|
19
|
+
if (!code) return null;
|
|
20
|
+
return { ...seo, code, value: seo.value ?? code };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Seeds local storage before the app bundle runs. The init script runs on the page's
|
|
25
|
+
* origin, which is what makes it work with the ephemeral prerender server.
|
|
26
|
+
* @param {import('playwright-core').Page} page
|
|
27
|
+
* @param {object} seo
|
|
28
|
+
*/
|
|
29
|
+
export async function seedPageStorage(page, seo) {
|
|
30
|
+
if (seo.source !== 'localStorage' && seo.source !== 'both') return;
|
|
31
|
+
await seedLocalStorage(page, { [seo.localStorageKey]: seo.value }, seo.overwrite);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seeds a fixed set of local storage entries before the app boots.
|
|
36
|
+
* @param {import('playwright-core').Page} page
|
|
37
|
+
* @param {Record<string, string>} entries
|
|
38
|
+
* @param {boolean} overwrite
|
|
39
|
+
*/
|
|
40
|
+
export async function seedLocalStorage(page, entries, overwrite = true) {
|
|
41
|
+
if (!entries || Object.keys(entries).length === 0) return;
|
|
42
|
+
await page.addInitScript(
|
|
43
|
+
({ values, replace }) => {
|
|
44
|
+
try {
|
|
45
|
+
for (const [key, value] of Object.entries(values)) {
|
|
46
|
+
if (replace || window.localStorage.getItem(key) === null) {
|
|
47
|
+
window.localStorage.setItem(key, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Local storage can be unavailable in private contexts; the app still renders its default.
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ values: entries, replace: overwrite },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Seeds the locale cookie in the browser context, using the current origin when no
|
|
60
|
+
* explicit domain is configured.
|
|
61
|
+
* @param {import('playwright-core').BrowserContext} context
|
|
62
|
+
* @param {string} origin
|
|
63
|
+
* @param {object} seo
|
|
64
|
+
*/
|
|
65
|
+
export async function seedContextCookie(context, origin, seo) {
|
|
66
|
+
if (seo.source !== 'cookie' && seo.source !== 'both') return;
|
|
67
|
+
if (!seo.overwrite) {
|
|
68
|
+
const existing = await context.cookies();
|
|
69
|
+
if (existing.some((cookie) => cookie.name === seo.cookie.name)) return;
|
|
70
|
+
}
|
|
71
|
+
await seedCookies(context, origin, [
|
|
72
|
+
{
|
|
73
|
+
...seo.cookie,
|
|
74
|
+
value: seo.value,
|
|
75
|
+
httpOnly: false,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Adds cookies with the same attributes to the render and verification contexts.
|
|
82
|
+
* @param {import('playwright-core').BrowserContext} context
|
|
83
|
+
* @param {string} origin
|
|
84
|
+
* @param {Array<object>} cookies
|
|
85
|
+
*/
|
|
86
|
+
export async function seedCookies(context, origin, cookies = []) {
|
|
87
|
+
if (!Array.isArray(cookies) || cookies.length === 0) return;
|
|
88
|
+
const entries = cookies.map((cookie) => {
|
|
89
|
+
const secure = cookie.secure === true && !origin.startsWith('http://');
|
|
90
|
+
const sameSite = normaliseSameSite(cookie.sameSite);
|
|
91
|
+
const entry = {
|
|
92
|
+
name: cookie.name,
|
|
93
|
+
value: String(cookie.value),
|
|
94
|
+
httpOnly: cookie.httpOnly === true,
|
|
95
|
+
secure,
|
|
96
|
+
// Browsers reject SameSite=None without Secure; keep local HTTP previews usable.
|
|
97
|
+
sameSite: sameSite === 'None' && !secure ? 'Lax' : sameSite,
|
|
98
|
+
expires:
|
|
99
|
+
cookie.maxAge !== null && cookie.maxAge !== undefined
|
|
100
|
+
? Math.floor(Date.now() / 1000) + cookie.maxAge
|
|
101
|
+
: -1,
|
|
102
|
+
};
|
|
103
|
+
if (cookie.domain) {
|
|
104
|
+
entry.domain = cookie.domain;
|
|
105
|
+
entry.path = cookie.path ?? '/';
|
|
106
|
+
} else {
|
|
107
|
+
entry.url = origin;
|
|
108
|
+
}
|
|
109
|
+
return entry;
|
|
110
|
+
});
|
|
111
|
+
await context.addCookies(entries);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds the small bootstrap script written into localized pages. Values are serialised as
|
|
116
|
+
* strings and cookie values are percent-encoded; no configuration value is interpolated
|
|
117
|
+
* as raw JavaScript.
|
|
118
|
+
* @param {object} seo
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function buildSeoLocaleScript(seo) {
|
|
122
|
+
const key = JSON.stringify(seo.localStorageKey);
|
|
123
|
+
const value = JSON.stringify(seo.value);
|
|
124
|
+
const cookieName = JSON.stringify(seo.cookie.name);
|
|
125
|
+
const cookieAttributes =
|
|
126
|
+
`; path=${seo.cookie.path ?? '/'}` +
|
|
127
|
+
(seo.cookie.domain ? `; domain=${seo.cookie.domain}` : '') +
|
|
128
|
+
(seo.cookie.maxAge !== null && seo.cookie.maxAge !== undefined
|
|
129
|
+
? `; max-age=${seo.cookie.maxAge}`
|
|
130
|
+
: '') +
|
|
131
|
+
`; SameSite=${normaliseSameSite(seo.cookie.sameSite)}` +
|
|
132
|
+
(seo.cookie.secure ? '; Secure' : '');
|
|
133
|
+
const cookieExpression = `${cookieName} + "=" + encodeURIComponent(${value}) + ${JSON.stringify(cookieAttributes)}`;
|
|
134
|
+
const localCondition = seo.overwrite ? 'true' : `window.localStorage.getItem(${key}) === null`;
|
|
135
|
+
const parts = ['(function () {'];
|
|
136
|
+
if (seo.source === 'localStorage' || seo.source === 'both') {
|
|
137
|
+
parts.push(
|
|
138
|
+
' try {',
|
|
139
|
+
` if (${localCondition}) {`,
|
|
140
|
+
` window.localStorage.setItem(${key}, ${value});`,
|
|
141
|
+
' }',
|
|
142
|
+
' } catch (error) {}',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (seo.source === 'cookie' || seo.source === 'both') {
|
|
146
|
+
parts.push(
|
|
147
|
+
' try {',
|
|
148
|
+
` var name = ${cookieName};`,
|
|
149
|
+
' var has = document.cookie.split("; ").some(function (part) {',
|
|
150
|
+
' return part.split("=")[0] === name;',
|
|
151
|
+
' });',
|
|
152
|
+
` if (${seo.overwrite ? 'true' : '!has'}) {`,
|
|
153
|
+
` document.cookie = ${cookieExpression};`,
|
|
154
|
+
' }',
|
|
155
|
+
' } catch (error) {}',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
parts.push('})();');
|
|
159
|
+
return parts.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Inserts the bootstrap before the first head script, or at the end of the head when the
|
|
164
|
+
* document has no script. A generated marker makes reruns replace the same script.
|
|
165
|
+
* @param {object} document parse5 document
|
|
166
|
+
* @param {string} script
|
|
167
|
+
* @param {string | null} externalHref
|
|
168
|
+
*/
|
|
169
|
+
export function injectSeoLocaleScript(document, script, externalHref = null) {
|
|
170
|
+
const head = findHead(document);
|
|
171
|
+
if (!head) return;
|
|
172
|
+
head.childNodes = (head.childNodes ?? []).filter(
|
|
173
|
+
(node) => !node.attrs?.some((entry) => entry.name === 'data-snappy-seo-locale'),
|
|
174
|
+
);
|
|
175
|
+
const node = externalHref
|
|
176
|
+
? {
|
|
177
|
+
nodeName: 'script',
|
|
178
|
+
tagName: 'script',
|
|
179
|
+
attrs: [
|
|
180
|
+
{ name: 'src', value: externalHref },
|
|
181
|
+
{ name: 'data-snappy-seo-locale', value: '' },
|
|
182
|
+
],
|
|
183
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
184
|
+
childNodes: [],
|
|
185
|
+
parentNode: null,
|
|
186
|
+
}
|
|
187
|
+
: {
|
|
188
|
+
nodeName: 'script',
|
|
189
|
+
tagName: 'script',
|
|
190
|
+
attrs: [{ name: 'data-snappy-seo-locale', value: '' }],
|
|
191
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
192
|
+
childNodes: [{ nodeName: '#text', value: script, parentNode: null }],
|
|
193
|
+
parentNode: null,
|
|
194
|
+
};
|
|
195
|
+
const scriptIndex = (head.childNodes ?? []).findIndex((child) => child.tagName === 'script');
|
|
196
|
+
if (scriptIndex === -1) head.childNodes.push(node);
|
|
197
|
+
else head.childNodes.splice(scriptIndex, 0, node);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Returns the current head language and direction controls for metadata injection.
|
|
202
|
+
* @param {object | null} seo
|
|
203
|
+
* @returns {{ htmlLang: string | boolean, dir: string | null, forceHtmlLang: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
export function seoLocaleMetadataOptions(seo) {
|
|
206
|
+
if (!seo) return { htmlLang: true, dir: null, forceHtmlLang: false };
|
|
207
|
+
return {
|
|
208
|
+
htmlLang: seo.htmlLang,
|
|
209
|
+
dir: seo.dir,
|
|
210
|
+
forceHtmlLang: seo.forceHtmlLang === true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalises a cookie SameSite value to the form Playwright and document.cookie expect.
|
|
216
|
+
* @param {string | undefined} value
|
|
217
|
+
* @returns {'Strict' | 'Lax' | 'None'}
|
|
218
|
+
*/
|
|
219
|
+
function normaliseSameSite(value) {
|
|
220
|
+
const text = String(value ?? 'Lax').toLowerCase();
|
|
221
|
+
if (text === 'strict') return 'Strict';
|
|
222
|
+
if (text === 'none') return 'None';
|
|
223
|
+
return 'Lax';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Finds the document head in a parsed document.
|
|
228
|
+
* @param {object} document
|
|
229
|
+
* @returns {object | null}
|
|
230
|
+
*/
|
|
231
|
+
function findHead(document) {
|
|
232
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
233
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
234
|
+
}
|
package/src/core/settle.js
CHANGED
|
@@ -112,15 +112,13 @@ export async function settlePage(page, config, tracker) {
|
|
|
112
112
|
* normalisation.
|
|
113
113
|
*/
|
|
114
114
|
export async function freezeAnimations(page) {
|
|
115
|
-
await page.evaluate((css) => {
|
|
115
|
+
await page.evaluate(async (css) => {
|
|
116
116
|
const style = document.createElement('style');
|
|
117
117
|
style.id = 'snappy-freeze';
|
|
118
118
|
style.textContent = css;
|
|
119
119
|
document.head.appendChild(style);
|
|
120
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
120
121
|
}, FREEZE_CSS);
|
|
121
|
-
await page.evaluate(
|
|
122
|
-
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
|
123
|
-
);
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
/**
|
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: file.localeUrl ?? `${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.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* rendering emits the same separators
|
|
2
|
+
* Final page snapshot: inserts comment separators between adjacent text nodes and
|
|
3
|
+
* returns the LCP URL and anchor hrefs in the same browser round trip. HTML cannot
|
|
4
|
+
* express two neighbouring text nodes, so serialising a client-rendered DOM merges
|
|
5
|
+
* them and hydration then finds one node where the client expects two. React's own
|
|
6
|
+
* server rendering emits the same separators.
|
|
7
7
|
*/
|
|
8
|
-
export async function
|
|
9
|
-
|
|
8
|
+
export async function readRenderSnapshot(page) {
|
|
9
|
+
return page.evaluate(() => {
|
|
10
10
|
// Elements whose children are raw text, where a comment would corrupt the content.
|
|
11
11
|
const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE']);
|
|
12
12
|
|
|
@@ -26,5 +26,9 @@ export async function separateTextNodes(page) {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
separate(document.documentElement);
|
|
29
|
+
return {
|
|
30
|
+
lcpUrl: window.__snappyLcp?.at(-1)?.url ?? null,
|
|
31
|
+
hrefs: Array.from(document.querySelectorAll('a[href]'), (anchor) => anchor.href),
|
|
32
|
+
};
|
|
29
33
|
});
|
|
30
34
|
}
|