snappy-prerender 2.7.0 → 2.8.1

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/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
@@ -12,10 +34,12 @@ const SINGLETON_LINK_RELS = new Set(['canonical']);
12
34
  * did not ask for.
13
35
  */
14
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
44
  if (typeof props.lang === 'string' && props.lang.length > 0) {
21
45
  entries.push({ kind: 'html', key: 'lang', value: props.lang });
@@ -23,9 +47,18 @@ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
23
47
  if (props.dir === 'ltr' || props.dir === 'rtl' || props.dir === 'auto') {
24
48
  entries.push({ kind: 'html', key: 'dir', value: props.dir });
25
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'));
26
55
 
27
- const title = props.title ? applyTitleTemplate(props.title, template, siteName) : null;
56
+ const title = props.title ? applyTitleTemplate(String(props.title), template, siteName) : null;
28
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
+ }
29
62
  if (title && props.openGraph !== false) entries.push(metaProp('og:title', title));
30
63
  if (title && props.twitter !== false) entries.push(metaName('twitter:title', title));
31
64
 
@@ -37,33 +70,52 @@ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
37
70
 
38
71
  if (props.robots) entries.push(metaName('robots', props.robots));
39
72
 
73
+ const images = resolveImages(props, defaults);
40
74
  if (props.openGraph !== false) {
41
75
  entries.push(metaProp('og:type', props.type ?? 'website'));
42
76
  if (siteName) entries.push(metaProp('og:site_name', siteName));
43
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
+ }
44
81
  const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
45
82
  if (url) entries.push(metaProp('og:url', url));
46
- if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
83
+ images.forEach((image, index) => {
84
+ entries.push(imageMeta(image, index, images.length));
85
+ });
47
86
  }
48
87
 
49
88
  if (props.twitter !== false) {
50
- entries.push(metaName('twitter:card', image ? 'summary_large_image' : 'summary'));
51
- 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]));
52
91
  }
53
92
 
54
93
  if (props.canonical !== false) {
55
94
  const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
56
- if (url)
57
- 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
+ }
58
103
  }
59
104
 
60
105
  for (const alternate of props.alternates ?? []) {
61
106
  const href = pageUrl(alternate.href, defaults);
62
107
  const hreflang = alternate.hreflang ?? alternate.lang;
63
108
  if (!href || !hreflang) continue;
64
- const attrs = { rel: 'alternate', hreflang, href };
65
- if (alternate.type) attrs.type = alternate.type;
66
- entries.push({ kind: 'link', key: `rel:alternate|${hreflang}|${href}`, attrs });
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 });
67
119
  }
68
120
 
69
121
  if (props.structuredData !== undefined && props.structuredData !== null) {
@@ -75,6 +127,11 @@ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
75
127
  });
76
128
  }
77
129
 
130
+ const tags = normaliseTags(props.tags);
131
+ tags.forEach((tag, index) => {
132
+ entries.push(headTagEntry(tag, id, index));
133
+ });
134
+
78
135
  for (const extra of props.extra ?? []) entries.push(extraEntry(extra));
79
136
 
80
137
  return entries;
@@ -83,7 +140,8 @@ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
83
140
  /**
84
141
  * Merges every mounted Head into one tag list. Later entries win per tag key, and the
85
142
  * render order recorded by each component decides "later", so a page's Head overrides the
86
- * 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.
87
145
  */
88
146
  export function mergeHeadEntries(stack = []) {
89
147
  const merged = new Map();
@@ -91,19 +149,28 @@ export function mergeHeadEntries(stack = []) {
91
149
  for (const entry of item.entries) merged.set(headKey(entry), entry);
92
150
  }
93
151
  const values = [...merged.values()];
94
- const titles = values.filter((entry) => entry.kind === 'title');
95
- const rest = values.filter((entry) => entry.kind !== 'title');
96
- 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)];
97
157
  }
98
158
 
99
159
  /**
100
160
  * Identifies a tag by what a browser or crawler matches on, not by who wrote it, so an
101
- * 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.
102
163
  */
103
164
  export function headKey(entry) {
104
165
  if (entry.kind === 'title') return 'title';
166
+ if (entry.kind === 'defaultTitle') return 'default-title';
105
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}`;
106
170
  if (entry.kind === 'script') return entry.key;
171
+ if (entry.kind === 'style' || entry.kind === 'noscript' || entry.kind === 'base') {
172
+ return entry.key;
173
+ }
107
174
  if (entry.kind === 'link') return `link:${entry.key ?? attrsKey(entry.attrs)}`;
108
175
  if (entry.key) return `meta:${entry.key}`;
109
176
  const name = attrsValue(entry.attrs, 'name');
@@ -178,30 +245,96 @@ function defaultPagePath(defaults) {
178
245
  });
179
246
  }
180
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.
181
297
  function metaName(name, content) {
182
- 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
+ };
183
304
  }
184
305
 
306
+ // Builds a single meta tag identified by its property attribute.
185
307
  function metaProp(property, content) {
186
- 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
+ };
187
314
  }
188
315
 
189
- // 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.
190
318
  function extraEntry(extra) {
319
+ const duplicates = extra.duplicates === true;
191
320
  const attrs = { ...extra };
192
321
  delete attrs.key;
322
+ delete attrs.duplicates;
193
323
  const kind = attrs.rel && !attrs.content ? 'link' : 'meta';
194
- const key = extra.key ?? (kind === 'link' ? linkKey(attrs) : identityKey(attrs));
195
- 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 };
196
327
  }
197
328
 
198
329
  // Keys a meta tag the same way the model keys its own, so a later Head can override it
199
330
  // instead of adding a duplicate.
200
- function identityKey(attrs) {
201
- if (attrs.name) return `name:${attrs.name}`;
202
- if (attrs.property) return `property:${attrs.property}`;
203
- if (attrs['http-equiv']) return `http-equiv:${attrs['http-equiv']}`;
204
- 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;
205
338
  }
206
339
 
207
340
  // Keys a link by rel, plus href for every rel a page may legitimately use more than once
@@ -209,15 +342,152 @@ function identityKey(attrs) {
209
342
  function linkKey(attrs) {
210
343
  const rel = String(attrs.rel);
211
344
  if (SINGLETON_LINK_RELS.has(rel.toLowerCase()) || !attrs.href) return `rel:${rel}`;
212
- 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;
361
+ }
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;
213
481
  }
214
482
 
483
+ // Joins every attribute into a stable key, used when an entry has no simpler identity.
215
484
  function attrsKey(attrs) {
216
485
  return Object.entries(attrs)
217
486
  .map(([name, value]) => `${name}=${value}`)
218
487
  .join('|');
219
488
  }
220
489
 
490
+ // Reads one attribute value from a plain attribute object.
221
491
  function attrsValue(attrs = {}, name) {
222
492
  return attrs[name];
223
493
  }
@@ -238,13 +508,16 @@ function serializeStructuredData(value) {
238
508
  );
239
509
  }
240
510
 
241
- // Keeps output deterministic: title, metas, links, JSON-LD scripts, then html attributes.
242
- function sortMetasFirst(entries) {
511
+ // Keeps output deterministic: title, base, metas, links, scripts, styles, then noscript.
512
+ function sortHeadOrder(entries) {
243
513
  const rank = (entry) => {
244
- if (entry.kind === 'meta') return 0;
245
- if (entry.kind === 'link') return 1;
246
- if (entry.kind === 'script') return 2;
247
- return 3;
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;
248
521
  };
249
522
  return [...entries].sort((a, b) => rank(a) - rank(b));
250
523
  }