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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. 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
+ }
@@ -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 { html: serialize(document), stats };
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
- // Adds a robots noindex meta to the not-found page, which may be served with a 200 by
179
- // a host that does not apply 404 status codes. Returns true when the tag was added.
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);
@@ -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
- * Reports whether a rendered page carries any app content. A missing container and an
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 isPageEmpty(page) {
7
+ export async function readPageState(page) {
7
8
  return page.evaluate(() => {
8
9
  const container = document.querySelector('#root, #app, #__next') ?? document.body;
9
- if (!container) return true;
10
- const text = (container.textContent ?? '').trim();
11
- return container.childElementCount === 0 && text.length === 0;
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
+ }