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,385 @@
|
|
|
1
|
+
import { pagePath } from './url-policy.js';
|
|
2
|
+
|
|
3
|
+
const GENERATED_ATTRIBUTE = 'data-snappy-locale';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the locale plan for a run. A seoLocale without a locales matrix becomes a
|
|
7
|
+
* synthetic single-locale plan so metadata and language attributes still work.
|
|
8
|
+
* @param {object} config
|
|
9
|
+
* @returns {object | null}
|
|
10
|
+
*/
|
|
11
|
+
export function buildLocalePlan(config) {
|
|
12
|
+
if (config.locales) return config.locales;
|
|
13
|
+
if (!config.seoLocale?.code) return null;
|
|
14
|
+
const seo = config.seoLocale;
|
|
15
|
+
return {
|
|
16
|
+
mode: 'single',
|
|
17
|
+
active: seo.code,
|
|
18
|
+
default: seo.code,
|
|
19
|
+
xDefault: false,
|
|
20
|
+
hreflang: false,
|
|
21
|
+
synthetic: true,
|
|
22
|
+
list: [
|
|
23
|
+
{
|
|
24
|
+
code: seo.code,
|
|
25
|
+
prefix: '',
|
|
26
|
+
htmlLang: seo.htmlLang === true ? seo.code : (seo.htmlLang ?? seo.code),
|
|
27
|
+
dir: seo.dir ?? null,
|
|
28
|
+
locale: seo.navigator ? seo.code : null,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns the locale record for a code.
|
|
36
|
+
* @param {object} plan
|
|
37
|
+
* @param {string} code
|
|
38
|
+
* @returns {object | null}
|
|
39
|
+
*/
|
|
40
|
+
export function localeByCode(plan, code) {
|
|
41
|
+
return plan?.list?.find((locale) => locale.code === code) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns the locale rendered by a single-mode run.
|
|
46
|
+
* @param {object} plan
|
|
47
|
+
* @returns {object | null}
|
|
48
|
+
*/
|
|
49
|
+
export function activeLocale(plan) {
|
|
50
|
+
if (!plan) return null;
|
|
51
|
+
if (plan.mode === 'single') return localeByCode(plan, plan.active ?? plan.default);
|
|
52
|
+
return localeByCode(plan, plan.default);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Joins a locale prefix and a route without producing duplicate slashes.
|
|
57
|
+
* @param {string} prefix
|
|
58
|
+
* @param {string} route
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function joinLocalePath(prefix, route) {
|
|
62
|
+
const left = prefix === '/' ? '' : prefix;
|
|
63
|
+
const right = route === '/' ? '' : route;
|
|
64
|
+
const joined = `${left}${right}`;
|
|
65
|
+
return joined.length === 0 ? '/' : joined.replace(/\/{2,}/g, '/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Maps a base route to the route a locale publishes.
|
|
70
|
+
* @param {object} locale
|
|
71
|
+
* @param {string} baseRoute
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function localeRoute(locale, baseRoute) {
|
|
75
|
+
const mapped = locale.routes?.[baseRoute] ?? baseRoute;
|
|
76
|
+
return joinLocalePath(locale.prefix ?? '', mapped);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Removes a locale prefix from a route, returning the base route used for alternate
|
|
81
|
+
* links and crawling.
|
|
82
|
+
* @param {string} route
|
|
83
|
+
* @param {object} locale
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function stripLocalePrefix(route, locale) {
|
|
87
|
+
const prefix = locale.prefix ?? '';
|
|
88
|
+
if (!prefix) return route;
|
|
89
|
+
if (route === prefix) return '/';
|
|
90
|
+
if (route.startsWith(`${prefix}/`)) return route.slice(prefix.length);
|
|
91
|
+
return route;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reports whether a route belongs to a non-default locale and should not be crawled as a
|
|
96
|
+
* base route in path mode.
|
|
97
|
+
* @param {string} route
|
|
98
|
+
* @param {object} plan
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function isLocalePrefixedRoute(route, plan) {
|
|
102
|
+
if (!plan || plan.mode !== 'path') return false;
|
|
103
|
+
const defaultLocale = localeByCode(plan, plan.default);
|
|
104
|
+
return plan.list.some(
|
|
105
|
+
(locale) =>
|
|
106
|
+
locale.code !== plan.default &&
|
|
107
|
+
locale.prefix &&
|
|
108
|
+
locale.prefix !== defaultLocale?.prefix &&
|
|
109
|
+
(route === locale.prefix || route.startsWith(`${locale.prefix}/`)),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolves the public URL for a base route in one locale.
|
|
115
|
+
* @param {object} locale
|
|
116
|
+
* @param {string} baseRoute
|
|
117
|
+
* @param {object} config
|
|
118
|
+
* @param {'path' | 'single'} mode
|
|
119
|
+
* @returns {string | null}
|
|
120
|
+
*/
|
|
121
|
+
export function localePageUrl(locale, baseRoute, config, mode = 'path') {
|
|
122
|
+
const base = locale.url ?? config.metadata?.siteUrl;
|
|
123
|
+
if (!base) return null;
|
|
124
|
+
const route =
|
|
125
|
+
mode === 'single' ? (locale.routes?.[baseRoute] ?? baseRoute) : localeRoute(locale, baseRoute);
|
|
126
|
+
const path = pagePath(route, {
|
|
127
|
+
flatOutput: config.flatOutput,
|
|
128
|
+
trailingSlash: config.metadata?.trailingSlash,
|
|
129
|
+
});
|
|
130
|
+
const prefix = config.base === '/' ? '' : config.base.slice(0, -1);
|
|
131
|
+
return `${base.replace(/\/$/, '')}${prefix}${path}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Builds the canonical URL, alternates and Open Graph locale values for one localized
|
|
136
|
+
* page. Explicit app metadata is handled later; this describes the defaults the package
|
|
137
|
+
* should fill.
|
|
138
|
+
* @param {{ locale: object, baseRoute: string, plan: object, config: object }} input
|
|
139
|
+
* @returns {object | null}
|
|
140
|
+
*/
|
|
141
|
+
export function buildLocaleLinks({ locale, baseRoute, plan, config }) {
|
|
142
|
+
if (!locale || !plan) return null;
|
|
143
|
+
const canonical = localePageUrl(locale, baseRoute, config, plan.mode);
|
|
144
|
+
const alternates = [];
|
|
145
|
+
if (plan.hreflang !== false) {
|
|
146
|
+
for (const target of plan.list) {
|
|
147
|
+
const href = localePageUrl(target, baseRoute, config, plan.mode);
|
|
148
|
+
if (href) alternates.push({ hreflang: target.code, href });
|
|
149
|
+
}
|
|
150
|
+
const xDefault = plan.xDefault;
|
|
151
|
+
if (xDefault === false) {
|
|
152
|
+
// Explicitly disabled.
|
|
153
|
+
} else if (typeof xDefault === 'string' && /^https?:\/\//.test(xDefault)) {
|
|
154
|
+
alternates.push({ hreflang: 'x-default', href: xDefault });
|
|
155
|
+
} else if (typeof xDefault === 'string') {
|
|
156
|
+
const target = localeByCode(plan, xDefault);
|
|
157
|
+
const href = target ? localePageUrl(target, baseRoute, config, plan.mode) : null;
|
|
158
|
+
if (href) alternates.push({ hreflang: 'x-default', href });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const ogAlternates = plan.list
|
|
162
|
+
.filter((target) => target.code !== locale.code && target.ogLocale)
|
|
163
|
+
.map((target) => target.ogLocale);
|
|
164
|
+
return {
|
|
165
|
+
htmlLang: locale.htmlLang ?? locale.code,
|
|
166
|
+
dir: locale.dir ?? null,
|
|
167
|
+
canonical,
|
|
168
|
+
ogLocale: locale.ogLocale ?? null,
|
|
169
|
+
ogAlternates,
|
|
170
|
+
alternates,
|
|
171
|
+
hreflang: plan.hreflang !== false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Applies locale defaults to a parsed document. Generated tags carry a marker so a rerun
|
|
177
|
+
* can replace them without touching metadata the app wrote. Existing app tags win.
|
|
178
|
+
* @param {object} document parse5 document
|
|
179
|
+
* @param {object} links result of buildLocaleLinks
|
|
180
|
+
* @param {{ skipAlternates?: boolean, htmlLang?: string | boolean, dir?: string | null, forceHtmlLang?: boolean }} options
|
|
181
|
+
*/
|
|
182
|
+
export function applyLocaleMetadata(document, links, options = {}) {
|
|
183
|
+
if (!links) return;
|
|
184
|
+
const head = findHead(document);
|
|
185
|
+
if (!head) return;
|
|
186
|
+
removeGenerated(head);
|
|
187
|
+
applyHtmlAttributes(document, links, options);
|
|
188
|
+
applyCanonical(head, links);
|
|
189
|
+
applyOpenGraph(head, links, options.skipAlternates === true);
|
|
190
|
+
if (!options.skipAlternates && links.hreflang) applyAlternates(head, links.alternates);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Writes generated html language and direction attributes while preserving app-owned
|
|
195
|
+
* values unless a locale explicitly forces them.
|
|
196
|
+
* @param {object} document
|
|
197
|
+
* @param {object} links
|
|
198
|
+
* @param {object} options
|
|
199
|
+
*/
|
|
200
|
+
function applyHtmlAttributes(document, links, options) {
|
|
201
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
202
|
+
if (!html) return;
|
|
203
|
+
const lang =
|
|
204
|
+
options.htmlLang === false
|
|
205
|
+
? null
|
|
206
|
+
: typeof options.htmlLang === 'string'
|
|
207
|
+
? options.htmlLang
|
|
208
|
+
: links.htmlLang;
|
|
209
|
+
const generatedLang = attribute(html, 'data-snappy-locale-lang') === 'true';
|
|
210
|
+
if (lang && (options.forceHtmlLang || !attribute(html, 'lang') || generatedLang)) {
|
|
211
|
+
setAttribute(html, 'lang', lang);
|
|
212
|
+
setAttribute(html, 'data-snappy-locale-lang', 'true');
|
|
213
|
+
}
|
|
214
|
+
const dir = options.dir ?? links.dir;
|
|
215
|
+
const generatedDir = attribute(html, 'data-snappy-locale-dir') === 'true';
|
|
216
|
+
if (dir && (!attribute(html, 'dir') || generatedDir)) {
|
|
217
|
+
setAttribute(html, 'dir', dir);
|
|
218
|
+
setAttribute(html, 'data-snappy-locale-dir', 'true');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Sets or replaces one attribute on a parse5 node.
|
|
224
|
+
* @param {object} node
|
|
225
|
+
* @param {string} name
|
|
226
|
+
* @param {string} value
|
|
227
|
+
*/
|
|
228
|
+
function setAttribute(node, name, value) {
|
|
229
|
+
const attrs = node.attrs ?? [];
|
|
230
|
+
const existing = attrs.find((entry) => entry.name === name);
|
|
231
|
+
if (existing) existing.value = value;
|
|
232
|
+
else attrs.push({ name, value });
|
|
233
|
+
node.attrs = attrs;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Adds the locale canonical link when the app did not provide one.
|
|
238
|
+
* @param {object} head
|
|
239
|
+
* @param {object} links
|
|
240
|
+
*/
|
|
241
|
+
function applyCanonical(head, links) {
|
|
242
|
+
if (!links.canonical) return;
|
|
243
|
+
const existing = head.childNodes?.find(
|
|
244
|
+
(node) => node.tagName === 'link' && attribute(node, 'rel').toLowerCase() === 'canonical',
|
|
245
|
+
);
|
|
246
|
+
if (existing) return;
|
|
247
|
+
head.childNodes.push(
|
|
248
|
+
linkNode([
|
|
249
|
+
{ name: 'rel', value: 'canonical' },
|
|
250
|
+
{ name: 'href', value: links.canonical },
|
|
251
|
+
{ name: GENERATED_ATTRIBUTE, value: 'canonical' },
|
|
252
|
+
]),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Adds Open Graph locale metadata without duplicating app-owned tags.
|
|
258
|
+
* @param {object} head
|
|
259
|
+
* @param {object} links
|
|
260
|
+
* @param {boolean} skipAlternates
|
|
261
|
+
*/
|
|
262
|
+
function applyOpenGraph(head, links, skipAlternates) {
|
|
263
|
+
if (links.ogLocale && !hasMeta(head, 'property', 'og:locale')) {
|
|
264
|
+
head.childNodes.push(metaNode('property', 'og:locale', links.ogLocale));
|
|
265
|
+
}
|
|
266
|
+
if (skipAlternates) return;
|
|
267
|
+
for (const locale of links.ogAlternates ?? []) {
|
|
268
|
+
if (hasMeta(head, 'property', 'og:locale:alternate', locale)) continue;
|
|
269
|
+
head.childNodes.push(metaNode('property', 'og:locale:alternate', locale));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Adds generated hreflang links, leaving an app-owned entry for the same code in place.
|
|
275
|
+
* @param {object} head
|
|
276
|
+
* @param {Array<{ hreflang: string, href: string }>} alternates
|
|
277
|
+
*/
|
|
278
|
+
function applyAlternates(head, alternates) {
|
|
279
|
+
for (const alternate of alternates ?? []) {
|
|
280
|
+
const existing = head.childNodes?.find(
|
|
281
|
+
(node) =>
|
|
282
|
+
node.tagName === 'link' &&
|
|
283
|
+
node.attrs?.some(
|
|
284
|
+
(entry) => entry.name === 'rel' && entry.value.toLowerCase() === 'alternate',
|
|
285
|
+
) &&
|
|
286
|
+
node.attrs?.some(
|
|
287
|
+
(entry) => entry.name === 'hreflang' && entry.value === alternate.hreflang,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
if (existing) continue;
|
|
291
|
+
head.childNodes.push(
|
|
292
|
+
linkNode([
|
|
293
|
+
{ name: 'rel', value: 'alternate' },
|
|
294
|
+
{ name: 'hreflang', value: alternate.hreflang },
|
|
295
|
+
{ name: 'href', value: alternate.href },
|
|
296
|
+
{ name: GENERATED_ATTRIBUTE, value: `hreflang:${alternate.hreflang}` },
|
|
297
|
+
]),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Removes locale tags generated by an earlier pass so a rerun can replace them.
|
|
304
|
+
* @param {object} head
|
|
305
|
+
*/
|
|
306
|
+
function removeGenerated(head) {
|
|
307
|
+
head.childNodes = (head.childNodes ?? []).filter(
|
|
308
|
+
(node) => !node.attrs?.some((entry) => entry.name === GENERATED_ATTRIBUTE),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Reports whether the head already contains a meta tag with the given identity.
|
|
314
|
+
* @param {object} head
|
|
315
|
+
* @param {string} attributeName
|
|
316
|
+
* @param {string} key
|
|
317
|
+
* @param {string | null} content
|
|
318
|
+
* @returns {boolean}
|
|
319
|
+
*/
|
|
320
|
+
function hasMeta(head, attributeName, key, content = null) {
|
|
321
|
+
return (head.childNodes ?? []).some(
|
|
322
|
+
(node) =>
|
|
323
|
+
node.tagName === 'meta' &&
|
|
324
|
+
attribute(node, attributeName) === key &&
|
|
325
|
+
(content === null || attribute(node, 'content') === content),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Finds the document head in a parsed document.
|
|
331
|
+
* @param {object} document
|
|
332
|
+
* @returns {object | null}
|
|
333
|
+
*/
|
|
334
|
+
function findHead(document) {
|
|
335
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
336
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Reads a parse5 node attribute.
|
|
341
|
+
* @param {object} node
|
|
342
|
+
* @param {string} name
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
function attribute(node, name) {
|
|
346
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Builds a parse5 link node.
|
|
351
|
+
* @param {Array<{ name: string, value: string }>} attrs
|
|
352
|
+
* @returns {object}
|
|
353
|
+
*/
|
|
354
|
+
function linkNode(attrs) {
|
|
355
|
+
return {
|
|
356
|
+
nodeName: 'link',
|
|
357
|
+
tagName: 'link',
|
|
358
|
+
attrs,
|
|
359
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
360
|
+
childNodes: [],
|
|
361
|
+
parentNode: null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Builds a parse5 meta node with the generated marker.
|
|
367
|
+
* @param {string} attributeName
|
|
368
|
+
* @param {string} key
|
|
369
|
+
* @param {string} content
|
|
370
|
+
* @returns {object}
|
|
371
|
+
*/
|
|
372
|
+
function metaNode(attributeName, key, content) {
|
|
373
|
+
return {
|
|
374
|
+
nodeName: 'meta',
|
|
375
|
+
tagName: 'meta',
|
|
376
|
+
attrs: [
|
|
377
|
+
{ name: attributeName, value: key },
|
|
378
|
+
{ name: 'content', value: content },
|
|
379
|
+
{ name: GENERATED_ATTRIBUTE, value: `${key}:${content}` },
|
|
380
|
+
],
|
|
381
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
382
|
+
childNodes: [],
|
|
383
|
+
parentNode: null,
|
|
384
|
+
};
|
|
385
|
+
}
|
package/src/core/normalize.js
CHANGED
|
@@ -12,7 +12,7 @@ const MAX_METADATA_ORIGINS = 3;
|
|
|
12
12
|
* Optionally: removes style or script tags, marks scripts async, drops dead blob
|
|
13
13
|
* stylesheets, and adds preconnect and image preload hints.
|
|
14
14
|
*/
|
|
15
|
-
export function normaliseHtml(html, options = {}) {
|
|
15
|
+
export function normaliseHtml(html, options = {}, { serialize: serializeOutput = true } = {}) {
|
|
16
16
|
const document = parse(html);
|
|
17
17
|
const stats = {
|
|
18
18
|
removedElements: 0,
|
|
@@ -21,7 +21,6 @@ export function normaliseHtml(html, options = {}) {
|
|
|
21
21
|
dedupedStyles: 0,
|
|
22
22
|
hints: 0,
|
|
23
23
|
textSeparators: 0,
|
|
24
|
-
titleElements: 0,
|
|
25
24
|
noindex: false,
|
|
26
25
|
metadataOrigins: [],
|
|
27
26
|
};
|
|
@@ -34,7 +33,11 @@ export function normaliseHtml(html, options = {}) {
|
|
|
34
33
|
if (preconnectOrigins.length > 0 || preloadImages.length > 0) {
|
|
35
34
|
stats.hints = appendHints(document, { preconnectOrigins, preloadImages });
|
|
36
35
|
}
|
|
37
|
-
return {
|
|
36
|
+
return {
|
|
37
|
+
html: serializeOutput ? serialize(document) : undefined,
|
|
38
|
+
stats,
|
|
39
|
+
document,
|
|
40
|
+
};
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
// Rebuilds a node's child list, dropping removed nodes, separating adjacent text nodes
|
|
@@ -50,7 +53,6 @@ function cleanChildren(parent, stats, seenStyles, inHead, options) {
|
|
|
50
53
|
stripAttributes(child, options.removeAttributes, stats);
|
|
51
54
|
}
|
|
52
55
|
if (childInHead && child.tagName === 'style' && dedupeStyle(child, stats, seenStyles)) continue;
|
|
53
|
-
if (childInHead && child.tagName === 'title') stats.titleElements += 1;
|
|
54
56
|
if (childInHead) collectMetadataOrigins(child, stats);
|
|
55
57
|
if (childInHead) collectRobots(child, stats);
|
|
56
58
|
if (options.asyncScriptTags && child.tagName === 'script') markScriptAsync(child);
|
|
@@ -117,6 +119,13 @@ function shouldStripMetadata(node, options, stats) {
|
|
|
117
119
|
function shouldRemove(node, stats, options) {
|
|
118
120
|
if (node.tagName === 'script' && hasAttribute(node, 'data-snappy-meta', null)) return false;
|
|
119
121
|
if (node.tagName === 'style' && hasAttribute(node, 'data-snappy-inline', null)) return false;
|
|
122
|
+
if (
|
|
123
|
+
node.tagName === 'meta' &&
|
|
124
|
+
attributeValue(node, 'name').toLowerCase() === 'prerender-status-code'
|
|
125
|
+
) {
|
|
126
|
+
stats.removedElements += 1;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
120
129
|
const reason =
|
|
121
130
|
(node.tagName === 'style' && hasAttribute(node, 'id', FREEZE_STYLE_ID)) ||
|
|
122
131
|
hasAttribute(node, REMOVE_ATTRIBUTE, null) ||
|
|
@@ -175,11 +184,24 @@ function isBlobStylesheet(node) {
|
|
|
175
184
|
return attributeValue(node, 'href').startsWith('blob:');
|
|
176
185
|
}
|
|
177
186
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
187
|
+
// Ensures the not-found page carries a robots noindex. An existing robots meta is updated in
|
|
188
|
+
// place so the page never ships both index and noindex directives. Returns true when the tag
|
|
189
|
+
// was added or changed.
|
|
180
190
|
function appendNoindex(document) {
|
|
181
191
|
const head = findHead(document);
|
|
182
192
|
if (!head) return false;
|
|
193
|
+
const robots = (head.childNodes ?? []).filter(
|
|
194
|
+
(node) => node.tagName === 'meta' && attributeValue(node, 'name').toLowerCase() === 'robots',
|
|
195
|
+
);
|
|
196
|
+
if (robots.length > 0) {
|
|
197
|
+
const [first, ...extras] = robots;
|
|
198
|
+
const content = attributeValue(first, 'content');
|
|
199
|
+
if (!/\bnoindex\b/i.test(content)) setAttributeValue(first, 'content', 'noindex');
|
|
200
|
+
if (extras.length > 0) {
|
|
201
|
+
head.childNodes = head.childNodes.filter((node) => !extras.includes(node));
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
183
205
|
head.childNodes.push(
|
|
184
206
|
createElement('meta', [
|
|
185
207
|
{ name: 'name', value: 'robots' },
|
|
@@ -189,6 +211,15 @@ function appendNoindex(document) {
|
|
|
189
211
|
return true;
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
// Sets or replaces one attribute on a parse5 node.
|
|
215
|
+
function setAttributeValue(node, name, value) {
|
|
216
|
+
const attrs = node.attrs ?? [];
|
|
217
|
+
const existing = attrs.find((attribute) => attribute.name === name);
|
|
218
|
+
if (existing) existing.value = value;
|
|
219
|
+
else attrs.push({ name, value });
|
|
220
|
+
node.attrs = attrs;
|
|
221
|
+
}
|
|
222
|
+
|
|
192
223
|
// Appends preconnect and image preload links to the head, skipping duplicates.
|
|
193
224
|
function appendHints(document, { preconnectOrigins, preloadImages }) {
|
|
194
225
|
const head = findHead(document);
|
package/src/core/page-content.js
CHANGED
|
@@ -1,13 +1,63 @@
|
|
|
1
|
+
// Containers used by the common setups, so probes can find the app without config.
|
|
2
|
+
export const CONTAINER_SELECTOR = '#root, #app, #__next';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
* empty container both mean the browser did not receive an app, which is what a 404,
|
|
4
|
-
* a failed bundle, or an empty build looks like after serialisation.
|
|
5
|
+
* Reads the empty-page and status-marker state in one browser round trip.
|
|
5
6
|
*/
|
|
6
|
-
export async function
|
|
7
|
+
export async function readPageState(page) {
|
|
7
8
|
return page.evaluate(() => {
|
|
8
9
|
const container = document.querySelector('#root, #app, #__next') ?? document.body;
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const text = (container?.textContent ?? '').trim();
|
|
11
|
+
const empty = !container || (container.childElementCount === 0 && text.length === 0);
|
|
12
|
+
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
13
|
+
const value = marker ? Number.parseInt(marker.getAttribute('content') ?? '', 10) : null;
|
|
14
|
+
return { empty, statusCode: Number.isFinite(value) ? value : null };
|
|
12
15
|
});
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the empty-page state, status marker, framework markers and boot state in one
|
|
20
|
+
* round trip. Verification runs this on every route, so the combined probe avoids three
|
|
21
|
+
* separate CDP calls.
|
|
22
|
+
*/
|
|
23
|
+
export async function readBootState(page, selector = CONTAINER_SELECTOR) {
|
|
24
|
+
return page.evaluate((containerSelector) => {
|
|
25
|
+
const pageContainer =
|
|
26
|
+
document.querySelector(containerSelector) ?? document.body?.firstElementChild ?? null;
|
|
27
|
+
const contentContainer = document.querySelector(containerSelector) ?? document.body;
|
|
28
|
+
const text = (contentContainer?.textContent ?? '').trim();
|
|
29
|
+
const empty =
|
|
30
|
+
!contentContainer || (contentContainer.childElementCount === 0 && text.length === 0);
|
|
31
|
+
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
32
|
+
const value = marker ? Number.parseInt(marker.getAttribute('content') ?? '', 10) : null;
|
|
33
|
+
const markers = pageContainer
|
|
34
|
+
? {
|
|
35
|
+
react: Object.keys(pageContainer).some(
|
|
36
|
+
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
37
|
+
),
|
|
38
|
+
vue: Boolean(
|
|
39
|
+
pageContainer.__vue_app__ ||
|
|
40
|
+
pageContainer.__vueParentComponent ||
|
|
41
|
+
pageContainer._vnode ||
|
|
42
|
+
pageContainer.__vue__,
|
|
43
|
+
),
|
|
44
|
+
preact: Boolean(
|
|
45
|
+
pageContainer.__k ||
|
|
46
|
+
pageContainer._children ||
|
|
47
|
+
pageContainer.firstElementChild?.__k ||
|
|
48
|
+
pageContainer.firstElementChild?._children,
|
|
49
|
+
),
|
|
50
|
+
svelte: Boolean(
|
|
51
|
+
pageContainer.__svelte_meta || pageContainer.querySelector?.('[data-svelte-h]'),
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
: {};
|
|
55
|
+
const child = window.__snappyBoot?.prerenderedChild ?? null;
|
|
56
|
+
return {
|
|
57
|
+
empty,
|
|
58
|
+
statusCode: Number.isFinite(value) ? value : null,
|
|
59
|
+
markers,
|
|
60
|
+
markupPreserved: child ? child.isConnected : null,
|
|
61
|
+
};
|
|
62
|
+
}, selector);
|
|
63
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
|
|
3
|
+
const DATA_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
4
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads the metadata and structural markers from a final HTML string.
|
|
8
|
+
*/
|
|
9
|
+
export function collectPageMeta(html) {
|
|
10
|
+
return collectPageMetaFromDocument(parse(html));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads metadata from an already parsed document. Used by the write pipeline so the
|
|
15
|
+
* final HTML is parsed once instead of once per post-processing step.
|
|
16
|
+
*/
|
|
17
|
+
export function collectPageMetaFromDocument(document) {
|
|
18
|
+
const meta = {
|
|
19
|
+
title: null,
|
|
20
|
+
titleCount: 0,
|
|
21
|
+
description: null,
|
|
22
|
+
canonical: null,
|
|
23
|
+
robots: null,
|
|
24
|
+
noindex: false,
|
|
25
|
+
lang: null,
|
|
26
|
+
dir: null,
|
|
27
|
+
ogLocale: null,
|
|
28
|
+
h1Count: 0,
|
|
29
|
+
alternates: [],
|
|
30
|
+
images: [],
|
|
31
|
+
inlineStyleAttributes: 0,
|
|
32
|
+
};
|
|
33
|
+
const visitedLinks = new Set();
|
|
34
|
+
|
|
35
|
+
const walk = (node) => {
|
|
36
|
+
if (node.tagName === 'html') {
|
|
37
|
+
meta.lang = attribute(node, 'lang') || null;
|
|
38
|
+
meta.dir = attribute(node, 'dir') || null;
|
|
39
|
+
}
|
|
40
|
+
// An SVG <title> names a drawing, not the document, so it must not count as a page title.
|
|
41
|
+
if (node.tagName === 'title' && node.namespaceURI !== SVG_NAMESPACE) {
|
|
42
|
+
meta.titleCount += 1;
|
|
43
|
+
if (meta.title === null) meta.title = textContent(node);
|
|
44
|
+
}
|
|
45
|
+
if (node.tagName === 'meta') {
|
|
46
|
+
const name = attribute(node, 'name').toLowerCase();
|
|
47
|
+
const property = attribute(node, 'property').toLowerCase();
|
|
48
|
+
const content = attribute(node, 'content');
|
|
49
|
+
if (name === 'description' && meta.description === null) meta.description = content;
|
|
50
|
+
if (property === 'og:locale' && meta.ogLocale === null) meta.ogLocale = content;
|
|
51
|
+
if (name === 'robots') {
|
|
52
|
+
meta.robots ??= content;
|
|
53
|
+
if (/\b(noindex|none)\b/i.test(content)) meta.noindex = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (node.tagName === 'link') {
|
|
57
|
+
const rel = attribute(node, 'rel').toLowerCase();
|
|
58
|
+
if (rel === 'canonical' && meta.canonical === null) meta.canonical = attribute(node, 'href');
|
|
59
|
+
if (rel.split(/\s+/).includes('alternate')) {
|
|
60
|
+
const hreflang = attribute(node, 'hreflang');
|
|
61
|
+
const href = attribute(node, 'href');
|
|
62
|
+
if (hreflang && href) {
|
|
63
|
+
const key = `${hreflang}|${href}`;
|
|
64
|
+
if (!visitedLinks.has(key)) {
|
|
65
|
+
visitedLinks.add(key);
|
|
66
|
+
meta.alternates.push({ hreflang, href });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (node.tagName === 'h1') meta.h1Count += 1;
|
|
72
|
+
if (node.tagName === 'img') {
|
|
73
|
+
addImage(meta, attribute(node, 'src'));
|
|
74
|
+
for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
|
|
75
|
+
addImage(meta, candidate);
|
|
76
|
+
}
|
|
77
|
+
if (node.tagName === 'source' && node.parentNode?.tagName === 'picture') {
|
|
78
|
+
addImage(meta, attribute(node, 'src'));
|
|
79
|
+
for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
|
|
80
|
+
addImage(meta, candidate);
|
|
81
|
+
}
|
|
82
|
+
for (const attr of node.attrs ?? []) {
|
|
83
|
+
if (attr.name === 'style' && attr.value.trim().length > 0) meta.inlineStyleAttributes += 1;
|
|
84
|
+
}
|
|
85
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
walk(document);
|
|
89
|
+
return meta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Adds a usable image URL once. Data URLs have no host to list in a sitemap.
|
|
93
|
+
function addImage(meta, value) {
|
|
94
|
+
if (!value || DATA_URL.test(value) || meta.images.includes(value)) return;
|
|
95
|
+
meta.images.push(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extracts URL candidates from a srcset, ignoring their density or width descriptors.
|
|
99
|
+
function srcsetCandidates(value) {
|
|
100
|
+
if (!value) return [];
|
|
101
|
+
return value
|
|
102
|
+
.split(',')
|
|
103
|
+
.map((candidate) => candidate.trim().split(/\s+/)[0])
|
|
104
|
+
.filter(
|
|
105
|
+
(candidate) => candidate.length > 0 && !DATA_URL.test(candidate) && !/[<>]/.test(candidate),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Reads an attribute value, defaulting to an empty string.
|
|
110
|
+
function attribute(node, name) {
|
|
111
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Concatenates the text beneath a node.
|
|
115
|
+
function textContent(node) {
|
|
116
|
+
let text = '';
|
|
117
|
+
for (const child of node.childNodes ?? []) {
|
|
118
|
+
if (child.nodeName === '#text') text += child.value;
|
|
119
|
+
else text += textContent(child);
|
|
120
|
+
}
|
|
121
|
+
return text.trim();
|
|
122
|
+
}
|