snappy-prerender 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- package/src/head/tags.js +349 -30
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
|
-
|
|
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
|
-
|
|
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',
|
|
44
|
-
if (
|
|
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({
|
|
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
|
|
70
|
-
const
|
|
71
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
if (attrs.
|
|
176
|
-
if (attrs
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
}
|