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
package/src/head/tags.js CHANGED
@@ -4,6 +4,28 @@ const ABSOLUTE = /^https?:\/\//i;
4
4
  const PROTOCOL_RELATIVE = /^\/\//;
5
5
  const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
6
6
  const SINGLETON_LINK_RELS = new Set(['canonical']);
7
+ const ALLOWED_HEAD_TAGS = new Set(['script', 'style', 'noscript', 'base']);
8
+ const RESERVED_ATTRIBUTES = new Set(['data-snappy-head', 'data-snappy-head-created']);
9
+ const ATTRIBUTE_NAME = /^[^\s"'<>/=]+$/;
10
+
11
+ // React prop names callers use in JSX, mapped to the attribute names the DOM stores. Helmet
12
+ // accepts the same spellings, so a migration does not need to rewrite attribute objects.
13
+ const ATTRIBUTE_ALIASES = Object.freeze({
14
+ acceptCharset: 'accept-charset',
15
+ charSet: 'charset',
16
+ className: 'class',
17
+ crossOrigin: 'crossorigin',
18
+ htmlFor: 'for',
19
+ httpEquiv: 'http-equiv',
20
+ itemID: 'itemid',
21
+ itemProp: 'itemprop',
22
+ itemRef: 'itemref',
23
+ itemScope: 'itemscope',
24
+ itemType: 'itemtype',
25
+ noModule: 'nomodule',
26
+ referrerPolicy: 'referrerpolicy',
27
+ srcSet: 'srcset',
28
+ });
7
29
 
8
30
  /**
9
31
  * Turns component props plus project defaults into the tag list the document head should
@@ -11,14 +33,32 @@ const SINGLETON_LINK_RELS = new Set(['canonical']);
11
33
  * caller did not supply are simply absent, so the component never invents metadata the app
12
34
  * did not ask for.
13
35
  */
14
- export function buildHeadEntries(props = {}, defaults = {}) {
36
+ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
37
+ if (props.children !== undefined && props.children !== null && props.children !== false) {
38
+ throw new TypeError('Head does not accept children; use tags, extra or the metadata props');
39
+ }
15
40
  const entries = [];
16
41
  const template = props.titleTemplate ?? defaults.titleTemplate ?? null;
17
42
  const siteName = props.siteName ?? defaults.siteName ?? null;
18
- const image = props.image ?? defaults.defaultImage ?? null;
19
43
 
20
- const title = props.title ? applyTitleTemplate(props.title, template, siteName) : null;
44
+ if (typeof props.lang === 'string' && props.lang.length > 0) {
45
+ entries.push({ kind: 'html', key: 'lang', value: props.lang });
46
+ }
47
+ if (props.dir === 'ltr' || props.dir === 'rtl' || props.dir === 'auto') {
48
+ entries.push({ kind: 'html', key: 'dir', value: props.dir });
49
+ }
50
+ // Attribute objects are pushed after the shortcuts so htmlAttributes.lang and
51
+ // htmlAttributes.dir win when both spellings are supplied.
52
+ entries.push(...attributeEntries('html', props.htmlAttributes, 'htmlAttributes'));
53
+ entries.push(...attributeEntries('body', props.bodyAttributes, 'bodyAttributes'));
54
+ entries.push(...attributeEntries('titleAttribute', props.titleAttributes, 'titleAttributes'));
55
+
56
+ const title = props.title ? applyTitleTemplate(String(props.title), template, siteName) : null;
21
57
  if (title) entries.push({ kind: 'title', text: title });
58
+ const defaultTitle = props.defaultTitle ?? defaults.defaultTitle ?? null;
59
+ if (!title && defaultTitle !== null && defaultTitle !== undefined && defaultTitle !== '') {
60
+ entries.push({ kind: 'defaultTitle', text: String(defaultTitle) });
61
+ }
22
62
  if (title && props.openGraph !== false) entries.push(metaProp('og:title', title));
23
63
  if (title && props.twitter !== false) entries.push(metaName('twitter:title', title));
24
64
 
@@ -30,26 +70,68 @@ export function buildHeadEntries(props = {}, defaults = {}) {
30
70
 
31
71
  if (props.robots) entries.push(metaName('robots', props.robots));
32
72
 
73
+ const images = resolveImages(props, defaults);
33
74
  if (props.openGraph !== false) {
34
75
  entries.push(metaProp('og:type', props.type ?? 'website'));
35
76
  if (siteName) entries.push(metaProp('og:site_name', siteName));
36
77
  if (props.locale) entries.push(metaProp('og:locale', props.locale));
78
+ for (const locale of normaliseStringList(props.alternateLocales, 'alternateLocales')) {
79
+ entries.push(metaDuplicate('property', 'og:locale:alternate', locale));
80
+ }
37
81
  const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
38
82
  if (url) entries.push(metaProp('og:url', url));
39
- if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
83
+ images.forEach((image, index) => {
84
+ entries.push(imageMeta(image, index, images.length));
85
+ });
40
86
  }
41
87
 
42
88
  if (props.twitter !== false) {
43
- entries.push(metaName('twitter:card', image ? 'summary_large_image' : 'summary'));
44
- if (image) entries.push(metaName('twitter:image', absoluteUrl(image, defaults)));
89
+ entries.push(metaName('twitter:card', images.length > 0 ? 'summary_large_image' : 'summary'));
90
+ if (images.length > 0) entries.push(metaName('twitter:image', images[0]));
45
91
  }
46
92
 
47
93
  if (props.canonical !== false) {
48
94
  const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
49
- if (url)
50
- entries.push({ kind: 'link', key: 'rel:canonical', attrs: { rel: 'canonical', href: url } });
95
+ if (url) {
96
+ entries.push({
97
+ kind: 'link',
98
+ key: 'rel:canonical',
99
+ match: [['rel', 'canonical']],
100
+ attrs: { rel: 'canonical', href: url },
101
+ });
102
+ }
51
103
  }
52
104
 
105
+ for (const alternate of props.alternates ?? []) {
106
+ const href = pageUrl(alternate.href, defaults);
107
+ const hreflang = alternate.hreflang ?? alternate.lang;
108
+ if (!href || !hreflang) continue;
109
+ const attrs = normaliseAttributes(
110
+ {
111
+ rel: 'alternate',
112
+ hreflang,
113
+ href,
114
+ ...(alternate.type ? { type: alternate.type } : {}),
115
+ },
116
+ 'alternates[].attrs',
117
+ );
118
+ entries.push({ kind: 'link', key: linkKey(attrs), match: linkMatch(attrs), attrs });
119
+ }
120
+
121
+ if (props.structuredData !== undefined && props.structuredData !== null) {
122
+ entries.push({
123
+ kind: 'script',
124
+ key: `script:ld+json:${id}`,
125
+ attrs: { type: 'application/ld+json' },
126
+ text: serializeStructuredData(props.structuredData),
127
+ });
128
+ }
129
+
130
+ const tags = normaliseTags(props.tags);
131
+ tags.forEach((tag, index) => {
132
+ entries.push(headTagEntry(tag, id, index));
133
+ });
134
+
53
135
  for (const extra of props.extra ?? []) entries.push(extraEntry(extra));
54
136
 
55
137
  return entries;
@@ -58,7 +140,8 @@ export function buildHeadEntries(props = {}, defaults = {}) {
58
140
  /**
59
141
  * Merges every mounted Head into one tag list. Later entries win per tag key, and the
60
142
  * render order recorded by each component decides "later", so a page's Head overrides the
61
- * layout's Head rather than the other way around.
143
+ * layout's Head rather than the other way around. A defaultTitle only fills the document
144
+ * title when no mounted Head supplied a real title.
62
145
  */
63
146
  export function mergeHeadEntries(stack = []) {
64
147
  const merged = new Map();
@@ -66,17 +149,28 @@ export function mergeHeadEntries(stack = []) {
66
149
  for (const entry of item.entries) merged.set(headKey(entry), entry);
67
150
  }
68
151
  const values = [...merged.values()];
69
- const titles = values.filter((entry) => entry.kind === 'title');
70
- const rest = values.filter((entry) => entry.kind !== 'title');
71
- return [...titles, ...sortMetasFirst(rest)];
152
+ const title = values.find((entry) => entry.kind === 'title') ?? null;
153
+ const fallback = values.find((entry) => entry.kind === 'defaultTitle') ?? null;
154
+ const resolvedTitle = title ?? (fallback ? { kind: 'title', text: fallback.text } : null);
155
+ const rest = values.filter((entry) => entry.kind !== 'title' && entry.kind !== 'defaultTitle');
156
+ return [...(resolvedTitle ? [resolvedTitle] : []), ...sortHeadOrder(rest)];
72
157
  }
73
158
 
74
159
  /**
75
160
  * Identifies a tag by what a browser or crawler matches on, not by who wrote it, so an
76
- * existing tag is updated in place rather than duplicated.
161
+ * existing tag is updated in place rather than duplicated. Entries that must keep several
162
+ * values in parallel carry their own key, which includes the value that makes them unique.
77
163
  */
78
164
  export function headKey(entry) {
79
165
  if (entry.kind === 'title') return 'title';
166
+ if (entry.kind === 'defaultTitle') return 'default-title';
167
+ if (entry.kind === 'html') return `html:${entry.key}`;
168
+ if (entry.kind === 'body') return `body:${entry.key}`;
169
+ if (entry.kind === 'titleAttribute') return `title-attr:${entry.key}`;
170
+ if (entry.kind === 'script') return entry.key;
171
+ if (entry.kind === 'style' || entry.kind === 'noscript' || entry.kind === 'base') {
172
+ return entry.key;
173
+ }
80
174
  if (entry.kind === 'link') return `link:${entry.key ?? attrsKey(entry.attrs)}`;
81
175
  if (entry.key) return `meta:${entry.key}`;
82
176
  const name = attrsValue(entry.attrs, 'name');
@@ -151,30 +245,96 @@ function defaultPagePath(defaults) {
151
245
  });
152
246
  }
153
247
 
248
+ // Resolves the single image or the ordered image list used by Open Graph and Twitter.
249
+ function resolveImages(props, defaults) {
250
+ const requested =
251
+ props.images !== undefined ? props.images : [props.image ?? defaults.defaultImage];
252
+ if (!Array.isArray(requested)) throw new TypeError('images must be an array of URLs');
253
+ const resolved = [];
254
+ for (const value of requested) {
255
+ if (value === null || value === undefined || value === '') continue;
256
+ if (typeof value !== 'string') throw new TypeError('images entries must be strings');
257
+ const url = absoluteUrl(value, defaults);
258
+ if (url) resolved.push(url);
259
+ }
260
+ return resolved;
261
+ }
262
+
263
+ // Builds one Open Graph image entry. The first image keeps the plain identity key so a
264
+ // single image still updates an existing tag in place; parallel images carry their URL in
265
+ // the key so every value survives the merge.
266
+ function imageMeta(url, index, total) {
267
+ if (total === 1) return metaProp('og:image', url);
268
+ if (index === 0) {
269
+ return {
270
+ kind: 'meta',
271
+ key: 'property:og:image',
272
+ match: [
273
+ ['property', 'og:image'],
274
+ ['content', url],
275
+ ],
276
+ adopt: [['property', 'og:image']],
277
+ attrs: { property: 'og:image', content: url },
278
+ };
279
+ }
280
+ return metaDuplicate('property', 'og:image', url);
281
+ }
282
+
283
+ // Builds a meta tag that may legitimately appear more than once, keyed by its content.
284
+ function metaDuplicate(attribute, identity, content) {
285
+ return {
286
+ kind: 'meta',
287
+ key: `${attribute}:${identity}|content=${content}`,
288
+ match: [
289
+ [attribute, identity],
290
+ ['content', content],
291
+ ],
292
+ attrs: { [attribute]: identity, content },
293
+ };
294
+ }
295
+
296
+ // Builds a single meta tag identified by its name attribute.
154
297
  function metaName(name, content) {
155
- return { kind: 'meta', key: `name:${name}`, attrs: { name, content } };
298
+ return {
299
+ kind: 'meta',
300
+ key: `name:${name}`,
301
+ match: [['name', name]],
302
+ attrs: { name, content },
303
+ };
156
304
  }
157
305
 
306
+ // Builds a single meta tag identified by its property attribute.
158
307
  function metaProp(property, content) {
159
- return { kind: 'meta', key: `property:${property}`, attrs: { property, content } };
308
+ return {
309
+ kind: 'meta',
310
+ key: `property:${property}`,
311
+ match: [['property', property]],
312
+ attrs: { property, content },
313
+ };
160
314
  }
161
315
 
162
- // Builds an entry from the caller's escape hatch for tags the model does not cover.
316
+ // Builds an entry from the caller's escape hatch for tags the model does not cover. A
317
+ // duplicates flag gives the entry a value-specific key so repeated same-name tags survive.
163
318
  function extraEntry(extra) {
319
+ const duplicates = extra.duplicates === true;
164
320
  const attrs = { ...extra };
165
321
  delete attrs.key;
322
+ delete attrs.duplicates;
166
323
  const kind = attrs.rel && !attrs.content ? 'link' : 'meta';
167
- const key = extra.key ?? (kind === 'link' ? linkKey(attrs) : identityKey(attrs));
168
- return { kind, key, attrs };
324
+ const key = extra.key ?? (kind === 'link' ? linkKey(attrs) : identityKey(attrs, duplicates));
325
+ const match = kind === 'link' ? linkMatch(attrs) : metaMatch(attrs, duplicates);
326
+ return { kind, key, match, attrs };
169
327
  }
170
328
 
171
329
  // Keys a meta tag the same way the model keys its own, so a later Head can override it
172
330
  // instead of adding a duplicate.
173
- function identityKey(attrs) {
174
- if (attrs.name) return `name:${attrs.name}`;
175
- if (attrs.property) return `property:${attrs.property}`;
176
- if (attrs['http-equiv']) return `http-equiv:${attrs['http-equiv']}`;
177
- return attrsKey(attrs);
331
+ function identityKey(attrs, duplicates = false) {
332
+ let key;
333
+ if (attrs.name) key = `name:${attrs.name}`;
334
+ else if (attrs.property) key = `property:${attrs.property}`;
335
+ else if (attrs['http-equiv']) key = `http-equiv:${attrs['http-equiv']}`;
336
+ else key = attrsKey(attrs);
337
+ return duplicates ? `${key}|${attrsKey(attrs)}` : key;
178
338
  }
179
339
 
180
340
  // Keys a link by rel, plus href for every rel a page may legitimately use more than once
@@ -182,23 +342,182 @@ function identityKey(attrs) {
182
342
  function linkKey(attrs) {
183
343
  const rel = String(attrs.rel);
184
344
  if (SINGLETON_LINK_RELS.has(rel.toLowerCase()) || !attrs.href) return `rel:${rel}`;
185
- return `rel:${rel}|${attrs.href}`;
345
+ const parts = [`rel:${rel}`];
346
+ if (attrs.hreflang) parts.push(attrs.hreflang);
347
+ parts.push(attrs.href);
348
+ return parts.join('|');
349
+ }
350
+
351
+ // Describes the attributes that identify a link for adoption. Canonical links match on rel
352
+ // alone so a new URL updates the existing tag instead of adding a second one.
353
+ function linkMatch(attrs) {
354
+ const rel = String(attrs.rel);
355
+ const pairs = [['rel', rel]];
356
+ if (!SINGLETON_LINK_RELS.has(rel.toLowerCase()) && attrs.href) {
357
+ pairs.push(['href', attrs.href]);
358
+ if (attrs.hreflang) pairs.push(['hreflang', attrs.hreflang]);
359
+ }
360
+ return pairs;
186
361
  }
187
362
 
363
+ // Describes the attributes that identify a meta tag. Duplicate-capable tags also match on
364
+ // every other supplied attribute so each value keeps its own element.
365
+ function metaMatch(attrs, duplicates = false) {
366
+ const pairs = [];
367
+ if (attrs.name) pairs.push(['name', attrs.name]);
368
+ else if (attrs.property) pairs.push(['property', attrs.property]);
369
+ else if (attrs['http-equiv']) pairs.push(['http-equiv', attrs['http-equiv']]);
370
+ if (!duplicates) return pairs;
371
+ for (const [name, value] of Object.entries(attrs)) {
372
+ if (name === 'name' || name === 'property' || name === 'http-equiv') continue;
373
+ pairs.push([name, value]);
374
+ }
375
+ return pairs;
376
+ }
377
+
378
+ // Validates and normalises the tags escape hatch.
379
+ function normaliseTags(tags) {
380
+ if (tags === undefined || tags === null) return [];
381
+ if (!Array.isArray(tags)) throw new TypeError('tags must be an array');
382
+ return tags.map((tag, index) => {
383
+ if (typeof tag !== 'object' || tag === null || Array.isArray(tag)) {
384
+ throw new TypeError(`tags[${index}] must be an object`);
385
+ }
386
+ if (!ALLOWED_HEAD_TAGS.has(tag.tag)) {
387
+ throw new TypeError(`tags[${index}].tag must be one of ${[...ALLOWED_HEAD_TAGS].join(', ')}`);
388
+ }
389
+ if (tag.html !== undefined && tag.tag !== 'noscript') {
390
+ throw new TypeError(`tags[${index}].html is only supported on noscript`);
391
+ }
392
+ if (
393
+ tag.text !== undefined &&
394
+ tag.text !== null &&
395
+ !['script', 'style', 'noscript'].includes(tag.tag)
396
+ ) {
397
+ throw new TypeError(`tags[${index}].text is only supported on script, style and noscript`);
398
+ }
399
+ if (tag.key !== undefined && (typeof tag.key !== 'string' || tag.key.length === 0)) {
400
+ throw new TypeError(`tags[${index}].key must be a non-empty string`);
401
+ }
402
+ return tag;
403
+ });
404
+ }
405
+
406
+ // Builds one script, style, noscript or base entry. Inline tags get a stable key scoped to
407
+ // the mounted Head so several of them can coexist; external scripts key by src.
408
+ function headTagEntry(tag, id, index) {
409
+ const attrs = normaliseAttributes(tag.attrs, `tags[${index}].attrs`);
410
+ const text = tag.text === undefined || tag.text === null ? undefined : String(tag.text);
411
+ const html = tag.html === undefined || tag.html === null ? undefined : String(tag.html);
412
+ let key;
413
+ if (tag.tag === 'base') key = 'base';
414
+ else if (tag.key) key = `${tag.tag}:key:${tag.key}`;
415
+ else if (tag.tag === 'script' && attrs.src) key = `script:src:${attrs.src}`;
416
+ else key = `${tag.tag}:inline:${id}:${index}`;
417
+ const entry = { kind: tag.tag, key, attrs };
418
+ if (text !== undefined) {
419
+ entry.text =
420
+ tag.tag === 'script' || tag.tag === 'style' ? escapeClosingTag(text, tag.tag) : text;
421
+ }
422
+ if (html !== undefined) entry.html = html;
423
+ return entry;
424
+ }
425
+
426
+ // Escapes a closing-tag sequence so a script or style value cannot terminate its own
427
+ // element. The backslash stays valid in JavaScript strings and CSS.
428
+ function escapeClosingTag(text, tag) {
429
+ return text.replace(new RegExp(`</${tag}`, 'gi'), (match) => `<\\/${match.slice(2)}`);
430
+ }
431
+
432
+ // Validates an attribute object and converts it to DOM attribute strings. Boolean true
433
+ // emits an empty attribute; false and null omit the attribute entirely.
434
+ function normaliseAttributes(attrs, context) {
435
+ if (attrs === undefined || attrs === null) return {};
436
+ if (typeof attrs !== 'object' || Array.isArray(attrs)) {
437
+ throw new TypeError(`${context} must be an object`);
438
+ }
439
+ const normalised = {};
440
+ for (const [rawName, rawValue] of Object.entries(attrs)) {
441
+ if (RESERVED_ATTRIBUTES.has(rawName)) {
442
+ throw new TypeError(`${context}.${rawName} is reserved by the head manager`);
443
+ }
444
+ if (!ATTRIBUTE_NAME.test(rawName)) {
445
+ throw new TypeError(`${context} contains an invalid attribute name: ${rawName}`);
446
+ }
447
+ const name = ATTRIBUTE_ALIASES[rawName] ?? rawName;
448
+ const value = normaliseAttributeValue(rawValue, `${context}.${rawName}`);
449
+ if (value === null) continue;
450
+ normalised[name] = value;
451
+ }
452
+ return normalised;
453
+ }
454
+
455
+ // Converts one attribute value, rejecting objects and functions instead of stringifying
456
+ // them into meaningless markup.
457
+ function normaliseAttributeValue(value, name) {
458
+ if (value === false || value === null || value === undefined) return null;
459
+ if (value === true) return '';
460
+ if (typeof value === 'string') return value;
461
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
462
+ throw new TypeError(`${name} must be a string, number, boolean, null or undefined`);
463
+ }
464
+
465
+ // Expands html, body and title attribute objects into mergeable attribute entries.
466
+ function attributeEntries(kind, attributes, context) {
467
+ const normalised = normaliseAttributes(attributes, context);
468
+ return Object.entries(normalised).map(([key, value]) => ({ kind, key, value }));
469
+ }
470
+
471
+ // Validates a list of strings, returning an empty list when the option is absent.
472
+ function normaliseStringList(list, name) {
473
+ if (list === undefined || list === null) return [];
474
+ if (!Array.isArray(list)) throw new TypeError(`${name} must be an array`);
475
+ for (const value of list) {
476
+ if (typeof value !== 'string' || value.length === 0) {
477
+ throw new TypeError(`${name} entries must be non-empty strings`);
478
+ }
479
+ }
480
+ return list;
481
+ }
482
+
483
+ // Joins every attribute into a stable key, used when an entry has no simpler identity.
188
484
  function attrsKey(attrs) {
189
485
  return Object.entries(attrs)
190
486
  .map(([name, value]) => `${name}=${value}`)
191
487
  .join('|');
192
488
  }
193
489
 
490
+ // Reads one attribute value from a plain attribute object.
194
491
  function attrsValue(attrs = {}, name) {
195
492
  return attrs[name];
196
493
  }
197
494
 
198
- // Keeps output deterministic: title, then name/property metas, then links.
199
- function sortMetasFirst(entries) {
200
- return [...entries].sort((a, b) => {
201
- const rank = (entry) => (entry.kind === 'meta' ? 0 : 1);
202
- return rank(a) - rank(b);
203
- });
495
+ // Serialises JSON-LD so a value can never close the script element. The escapes remain
496
+ // valid JSON, so crawlers parse the same object the caller supplied.
497
+ function serializeStructuredData(value) {
498
+ return JSON.stringify(value).replace(
499
+ /[<>&\u2028\u2029]/g,
500
+ (char) =>
501
+ ({
502
+ '<': '\\u003C',
503
+ '>': '\\u003E',
504
+ '&': '\\u0026',
505
+ '\u2028': '\\u2028',
506
+ '\u2029': '\\u2029',
507
+ })[char],
508
+ );
509
+ }
510
+
511
+ // Keeps output deterministic: title, base, metas, links, scripts, styles, then noscript.
512
+ function sortHeadOrder(entries) {
513
+ const rank = (entry) => {
514
+ if (entry.kind === 'base') return 0;
515
+ if (entry.kind === 'meta') return 1;
516
+ if (entry.kind === 'link') return 2;
517
+ if (entry.kind === 'script') return 3;
518
+ if (entry.kind === 'style') return 4;
519
+ if (entry.kind === 'noscript') return 5;
520
+ return 6;
521
+ };
522
+ return [...entries].sort((a, b) => rank(a) - rank(b));
204
523
  }