snappy-prerender 2.7.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.
@@ -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);
@@ -182,11 +184,24 @@ function isBlobStylesheet(node) {
182
184
  return attributeValue(node, 'href').startsWith('blob:');
183
185
  }
184
186
 
185
- // Adds a robots noindex meta to the not-found page, which may be served with a 200 by
186
- // 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.
187
190
  function appendNoindex(document) {
188
191
  const head = findHead(document);
189
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
+ }
190
205
  head.childNodes.push(
191
206
  createElement('meta', [
192
207
  { name: 'name', value: 'robots' },
@@ -196,6 +211,15 @@ function appendNoindex(document) {
196
211
  return true;
197
212
  }
198
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
+
199
223
  // Appends preconnect and image preload links to the head, skipping duplicates.
200
224
  function appendHints(document, { preconnectOrigins, preloadImages }) {
201
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
+ }
@@ -4,12 +4,17 @@ const DATA_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
4
4
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
5
5
 
6
6
  /**
7
- * Reads the metadata and structural markers from a final HTML document. This runs
8
- * after post-processing so a transform that changes the head is reflected in the
9
- * sitemap, the audit and the report.
7
+ * Reads the metadata and structural markers from a final HTML string.
10
8
  */
11
9
  export function collectPageMeta(html) {
12
- const document = parse(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) {
13
18
  const meta = {
14
19
  title: null,
15
20
  titleCount: 0,
@@ -17,6 +22,9 @@ export function collectPageMeta(html) {
17
22
  canonical: null,
18
23
  robots: null,
19
24
  noindex: false,
25
+ lang: null,
26
+ dir: null,
27
+ ogLocale: null,
20
28
  h1Count: 0,
21
29
  alternates: [],
22
30
  images: [],
@@ -25,6 +33,10 @@ export function collectPageMeta(html) {
25
33
  const visitedLinks = new Set();
26
34
 
27
35
  const walk = (node) => {
36
+ if (node.tagName === 'html') {
37
+ meta.lang = attribute(node, 'lang') || null;
38
+ meta.dir = attribute(node, 'dir') || null;
39
+ }
28
40
  // An SVG <title> names a drawing, not the document, so it must not count as a page title.
29
41
  if (node.tagName === 'title' && node.namespaceURI !== SVG_NAMESPACE) {
30
42
  meta.titleCount += 1;
@@ -32,8 +44,10 @@ export function collectPageMeta(html) {
32
44
  }
33
45
  if (node.tagName === 'meta') {
34
46
  const name = attribute(node, 'name').toLowerCase();
47
+ const property = attribute(node, 'property').toLowerCase();
35
48
  const content = attribute(node, 'content');
36
49
  if (name === 'description' && meta.description === null) meta.description = content;
50
+ if (property === 'og:locale' && meta.ogLocale === null) meta.ogLocale = content;
37
51
  if (name === 'robots') {
38
52
  meta.robots ??= content;
39
53
  if (/\b(noindex|none)\b/i.test(content)) meta.noindex = true;
@@ -7,8 +7,22 @@ import { toPublicPath } from './routes.js';
7
7
  * preload-manifest headers, so this is the page-level equivalent for runtime chunks,
8
8
  * fonts and the LCP image.
9
9
  */
10
- export function injectPreloadLinks(
11
- html,
10
+ export function injectPreloadLinks(html, options = {}) {
11
+ if (options.scripts?.length === 0 && options.fonts?.size === 0 && !options.lcpImage) {
12
+ return html;
13
+ }
14
+ const document = parse(html);
15
+ const added = injectPreloadLinksInto(document, options);
16
+ return added > 0 ? serialize(document) : html;
17
+ }
18
+
19
+ /**
20
+ * Injects preload hints into a parsed document and reports how many were added.
21
+ * Keeping this separate lets the write pipeline parse the page once for all final
22
+ * post-processing steps.
23
+ */
24
+ export function injectPreloadLinksInto(
25
+ document,
12
26
  {
13
27
  scripts = [],
14
28
  fonts = new Map(),
@@ -18,9 +32,8 @@ export function injectPreloadLinks(
18
32
  skipScripts = new Set(),
19
33
  } = {},
20
34
  ) {
21
- const document = parse(html);
22
35
  const head = findHead(document);
23
- if (!head) return html;
36
+ if (!head) return 0;
24
37
  const existingScripts = new Set();
25
38
  const hints = new Set();
26
39
 
@@ -46,7 +59,7 @@ export function injectPreloadLinks(
46
59
  added += 1;
47
60
  };
48
61
 
49
- for (const script of scripts) {
62
+ for (const script of [...scripts].sort()) {
50
63
  const href = toPublicPath(script, base);
51
64
  if (existingScripts.has(href) || skipScripts.has(href)) continue;
52
65
  add(
@@ -57,7 +70,9 @@ export function injectPreloadLinks(
57
70
  href,
58
71
  );
59
72
  }
60
- for (const [font, contentType] of fonts) {
73
+ for (const [font, contentType] of [...fonts.entries()].sort(([left], [right]) =>
74
+ left.localeCompare(right),
75
+ )) {
61
76
  const href = toPublicPath(font, base);
62
77
  const attrs = [
63
78
  { name: 'rel', value: 'preload' },
@@ -80,13 +95,11 @@ export function injectPreloadLinks(
80
95
  href,
81
96
  );
82
97
  }
83
- return added > 0 ? serialize(document) : html;
98
+ return added;
84
99
  }
85
100
 
86
- // Collects script src values from a document, used to tell a script a transform
87
- // removed from a chunk the app loads at runtime.
88
- export function collectScriptSources(html) {
89
- const document = parse(html);
101
+ // Collects script src values from an already parsed document.
102
+ export function collectScriptSourcesFromDocument(document) {
90
103
  const sources = new Set();
91
104
  const walk = (node) => {
92
105
  if (node.tagName === 'script') {
@@ -12,8 +12,12 @@ const PAGE_ORIGIN = 'http://snappy.local';
12
12
  * output rather than the render-time resource list keeps the manifest correct after
13
13
  * inline CSS, tag surgery, or a failed write.
14
14
  */
15
- export function collectPageResources(html, { base = '/', route = '/' } = {}) {
16
- const document = parse(html);
15
+ export function collectPageResources(html, options = {}) {
16
+ return collectPageResourcesFromDocument(parse(html), options);
17
+ }
18
+
19
+ // Reads referenced resources from an already parsed document.
20
+ export function collectPageResourcesFromDocument(document, { base = '/', route = '/' } = {}) {
17
21
  const scripts = [];
18
22
  const styles = [];
19
23
  const pageUrl = new URL(toPublicPath(route, base), PAGE_ORIGIN);