@bleedingdev/modern-js-runtime-extensions 3.8.3-ultramodern.2
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/LICENSE +21 -0
- package/dist/cjs/helmetCompat.js +419 -0
- package/dist/cjs/html.js +45 -0
- package/dist/cjs/index.js +83 -0
- package/dist/cjs/navigationWarmup.js +59 -0
- package/dist/cjs/node.js +143 -0
- package/dist/cjs/rendererHead.js +263 -0
- package/dist/cjs/rscHtmlStream.js +128 -0
- package/dist/cjs/safeFailure.js +125 -0
- package/dist/cjs/shellStream.js +76 -0
- package/dist/esm/helmetCompat.mjs +357 -0
- package/dist/esm/html.mjs +4 -0
- package/dist/esm/index.mjs +4 -0
- package/dist/esm/navigationWarmup.mjs +21 -0
- package/dist/esm/node.mjs +61 -0
- package/dist/esm/rendererHead.mjs +162 -0
- package/dist/esm/rscHtmlStream.mjs +86 -0
- package/dist/esm/safeFailure.mjs +78 -0
- package/dist/esm/shellStream.mjs +38 -0
- package/dist/esm-node/helmetCompat.mjs +358 -0
- package/dist/esm-node/html.mjs +5 -0
- package/dist/esm-node/index.mjs +5 -0
- package/dist/esm-node/navigationWarmup.mjs +22 -0
- package/dist/esm-node/node.mjs +62 -0
- package/dist/esm-node/rendererHead.mjs +163 -0
- package/dist/esm-node/rscHtmlStream.mjs +87 -0
- package/dist/esm-node/safeFailure.mjs +79 -0
- package/dist/esm-node/shellStream.mjs +39 -0
- package/dist/types/helmetCompat.d.ts +75 -0
- package/dist/types/html.d.ts +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/navigationWarmup.d.ts +8 -0
- package/dist/types/node.d.ts +11 -0
- package/dist/types/rendererHead.d.ts +17 -0
- package/dist/types/rscHtmlStream.d.ts +4 -0
- package/dist/types/safeFailure.d.ts +17 -0
- package/dist/types/shellStream.d.ts +1 -0
- package/package.json +90 -0
- package/rslib.config.mts +4 -0
- package/rstest.config.mts +7 -0
- package/src/helmetCompat.ts +628 -0
- package/src/html.ts +11 -0
- package/src/index.ts +4 -0
- package/src/navigationWarmup.ts +39 -0
- package/src/node.ts +93 -0
- package/src/rendererHead.ts +236 -0
- package/src/rscHtmlStream.ts +142 -0
- package/src/safeFailure.ts +137 -0
- package/src/shellStream.ts +53 -0
- package/tests/rendererHead.test.ts +299 -0
- package/tests/rscHtmlStream.test.ts +153 -0
- package/tests/safeFailure.test.ts +154 -0
- package/tests/shellStream.test.ts +85 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
type HeadTag = Record<string, unknown>;
|
|
2
|
+
type HelmetTagName = 'base' | 'link' | 'meta' | 'noscript' | 'script' | 'style';
|
|
3
|
+
type PriorityTagName = 'link' | 'meta' | 'script';
|
|
4
|
+
type DedupableTagName = Exclude<HelmetTagName, 'base'>;
|
|
5
|
+
|
|
6
|
+
type ElementFactory<Element> = {
|
|
7
|
+
createElement: (...args: never[]) => Element;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ReactAdapter<Element, Child> = ElementFactory<Element> & {
|
|
11
|
+
Children: {
|
|
12
|
+
forEach: (
|
|
13
|
+
children: Child | undefined,
|
|
14
|
+
visit: (child: unknown) => void,
|
|
15
|
+
) => void;
|
|
16
|
+
toArray: (children: Child | undefined) => unknown[];
|
|
17
|
+
};
|
|
18
|
+
Fragment: unknown;
|
|
19
|
+
isValidElement: (value: unknown) => boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type HeadElement<Child> = {
|
|
23
|
+
props: HeadTag & { children?: Child };
|
|
24
|
+
type: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type HeadDatum<Element> = {
|
|
28
|
+
toComponent: () => Element[];
|
|
29
|
+
toString: () => string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type HeadAttributeDatum = {
|
|
33
|
+
toComponent: () => Record<string, unknown>;
|
|
34
|
+
toString: () => string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type MarkerProps = Record<'data-modern-helmet', string>;
|
|
38
|
+
|
|
39
|
+
export type HelmetCompatProps<Child = unknown> = {
|
|
40
|
+
base?: object;
|
|
41
|
+
bodyAttributes?: object;
|
|
42
|
+
children?: Child;
|
|
43
|
+
defaultTitle?: string;
|
|
44
|
+
htmlAttributes?: object;
|
|
45
|
+
link?: object[];
|
|
46
|
+
meta?: object[];
|
|
47
|
+
noscript?: object[];
|
|
48
|
+
prioritizeSeoTags?: boolean;
|
|
49
|
+
script?: object[];
|
|
50
|
+
style?: object[];
|
|
51
|
+
title?: unknown;
|
|
52
|
+
titleAttributes?: object;
|
|
53
|
+
titleTemplate?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type HelmetCompatRecord = {
|
|
57
|
+
base: HeadTag[];
|
|
58
|
+
bodyAttributes?: HeadTag;
|
|
59
|
+
defaultTitle?: string;
|
|
60
|
+
htmlAttributes?: HeadTag;
|
|
61
|
+
link: HeadTag[];
|
|
62
|
+
meta: HeadTag[];
|
|
63
|
+
noscript: HeadTag[];
|
|
64
|
+
prioritizeSeoTags: boolean;
|
|
65
|
+
script: HeadTag[];
|
|
66
|
+
style: HeadTag[];
|
|
67
|
+
title?: string;
|
|
68
|
+
titleAttributes: HeadTag;
|
|
69
|
+
titleTemplate?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type HelmetCompatState<Element> = {
|
|
73
|
+
base: HeadDatum<Element>;
|
|
74
|
+
bodyAttributes: HeadAttributeDatum;
|
|
75
|
+
htmlAttributes: HeadAttributeDatum;
|
|
76
|
+
link: HeadDatum<Element>;
|
|
77
|
+
meta: HeadDatum<Element>;
|
|
78
|
+
noscript: HeadDatum<Element>;
|
|
79
|
+
priority: HeadDatum<Element>;
|
|
80
|
+
script: HeadDatum<Element>;
|
|
81
|
+
style: HeadDatum<Element>;
|
|
82
|
+
title: HeadDatum<Element>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const CONTENT_PROPERTIES = new Set(['innerHTML', 'cssText']);
|
|
86
|
+
const HTML_ATTRIBUTE_NAMES: Record<string, string> = {
|
|
87
|
+
charSet: 'charset',
|
|
88
|
+
className: 'class',
|
|
89
|
+
contentEditable: 'contenteditable',
|
|
90
|
+
httpEquiv: 'http-equiv',
|
|
91
|
+
hrefLang: 'hreflang',
|
|
92
|
+
itemProp: 'itemprop',
|
|
93
|
+
tabIndex: 'tabindex',
|
|
94
|
+
};
|
|
95
|
+
const REACT_ATTRIBUTE_NAMES: Record<string, string> = {
|
|
96
|
+
accesskey: 'accessKey',
|
|
97
|
+
charset: 'charSet',
|
|
98
|
+
class: 'className',
|
|
99
|
+
contenteditable: 'contentEditable',
|
|
100
|
+
contextmenu: 'contextMenu',
|
|
101
|
+
'http-equiv': 'httpEquiv',
|
|
102
|
+
hreflang: 'hrefLang',
|
|
103
|
+
itemprop: 'itemProp',
|
|
104
|
+
tabindex: 'tabIndex',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const SEO_PRIORITY_VALUES: Record<PriorityTagName, Record<string, string[]>> = {
|
|
108
|
+
link: { rel: ['amphtml', 'canonical', 'alternate'] },
|
|
109
|
+
meta: {
|
|
110
|
+
charset: [''],
|
|
111
|
+
name: ['generator', 'robots', 'description'],
|
|
112
|
+
property: [
|
|
113
|
+
'og:type',
|
|
114
|
+
'og:title',
|
|
115
|
+
'og:url',
|
|
116
|
+
'og:image',
|
|
117
|
+
'og:image:alt',
|
|
118
|
+
'og:description',
|
|
119
|
+
'twitter:url',
|
|
120
|
+
'twitter:title',
|
|
121
|
+
'twitter:description',
|
|
122
|
+
'twitter:image',
|
|
123
|
+
'twitter:image:alt',
|
|
124
|
+
'twitter:card',
|
|
125
|
+
'twitter:site',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
script: { type: ['application/ld+json'] },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const TAG_PRIMARY_ATTRIBUTES: Record<DedupableTagName, string[]> = {
|
|
132
|
+
link: ['rel', 'href'],
|
|
133
|
+
meta: ['name', 'charset', 'http-equiv', 'property', 'itemprop'],
|
|
134
|
+
noscript: ['innerHTML'],
|
|
135
|
+
script: ['src', 'innerHTML'],
|
|
136
|
+
style: ['cssText'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const priorityRecords = new WeakSet<object>();
|
|
140
|
+
const priorityTags = new WeakSet<object>();
|
|
141
|
+
const immediateRecords = new WeakMap<object, HelmetCompatRecord[]>();
|
|
142
|
+
|
|
143
|
+
const createElementFactory = <Element>(react: ElementFactory<Element>) =>
|
|
144
|
+
react.createElement as unknown as (
|
|
145
|
+
type: string,
|
|
146
|
+
props: Record<string, unknown>,
|
|
147
|
+
...children: unknown[]
|
|
148
|
+
) => Element;
|
|
149
|
+
|
|
150
|
+
export const renderHeadMarker = <Element>(
|
|
151
|
+
react: ElementFactory<Element>,
|
|
152
|
+
marker: MarkerProps | null | undefined,
|
|
153
|
+
): Element | null =>
|
|
154
|
+
marker ? createElementFactory(react)('template', marker) : null;
|
|
155
|
+
|
|
156
|
+
const toReactAttributes = (tag: HeadTag): Record<string, unknown> => {
|
|
157
|
+
const props: Record<string, unknown> = {};
|
|
158
|
+
for (const [attribute, value] of Object.entries(tag)) {
|
|
159
|
+
if (!CONTENT_PROPERTIES.has(attribute)) {
|
|
160
|
+
props[REACT_ATTRIBUTE_NAMES[attribute] ?? attribute] = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return props;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const toReactProps = (tag: HeadTag, key: unknown): Record<string, unknown> => {
|
|
167
|
+
const props: Record<string, unknown> = {
|
|
168
|
+
...toReactAttributes(tag),
|
|
169
|
+
key,
|
|
170
|
+
'data-rh': true,
|
|
171
|
+
};
|
|
172
|
+
if ('innerHTML' in tag || 'cssText' in tag) {
|
|
173
|
+
props.dangerouslySetInnerHTML = {
|
|
174
|
+
__html: tag.innerHTML ?? tag.cssText ?? '',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return props;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const escapeHtml = (value: unknown): string =>
|
|
181
|
+
String(value)
|
|
182
|
+
.replaceAll('&', '&')
|
|
183
|
+
.replaceAll('"', '"')
|
|
184
|
+
.replaceAll('<', '<')
|
|
185
|
+
.replaceAll('>', '>');
|
|
186
|
+
|
|
187
|
+
const toHtmlAttributeName = (name: string): string =>
|
|
188
|
+
HTML_ATTRIBUTE_NAMES[name] ?? name;
|
|
189
|
+
|
|
190
|
+
const attributesToString = (
|
|
191
|
+
tag: HeadTag,
|
|
192
|
+
includeHelmetAttribute = false,
|
|
193
|
+
): string => {
|
|
194
|
+
const attributes = includeHelmetAttribute ? ['data-rh="true"'] : [];
|
|
195
|
+
for (const [name, value] of Object.entries(tag)) {
|
|
196
|
+
if (
|
|
197
|
+
CONTENT_PROPERTIES.has(name) ||
|
|
198
|
+
value === false ||
|
|
199
|
+
value === null ||
|
|
200
|
+
value === undefined
|
|
201
|
+
) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const attribute = toHtmlAttributeName(name);
|
|
205
|
+
attributes.push(
|
|
206
|
+
value === true ? attribute : `${attribute}="${escapeHtml(value)}"`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return attributes.join(' ');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const tagsToString = (tagName: HelmetTagName, tags: HeadTag[]): string =>
|
|
213
|
+
tags
|
|
214
|
+
.map(tag => {
|
|
215
|
+
const attributes = attributesToString(tag, true);
|
|
216
|
+
if (
|
|
217
|
+
(tagName === 'script' || tagName === 'noscript') &&
|
|
218
|
+
typeof tag.innerHTML === 'string'
|
|
219
|
+
) {
|
|
220
|
+
return `<${tagName} ${attributes}>${tag.innerHTML}</${tagName}>`;
|
|
221
|
+
}
|
|
222
|
+
if (tagName === 'style' && typeof tag.cssText === 'string') {
|
|
223
|
+
return `<style ${attributes}>${tag.cssText}</style>`;
|
|
224
|
+
}
|
|
225
|
+
return `<${tagName} ${attributes}>`;
|
|
226
|
+
})
|
|
227
|
+
.join('');
|
|
228
|
+
|
|
229
|
+
export const markPriorityHeadRecord = (
|
|
230
|
+
record: object,
|
|
231
|
+
prioritize: boolean | undefined,
|
|
232
|
+
): void => {
|
|
233
|
+
if (prioritize) {
|
|
234
|
+
priorityRecords.add(record);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const isPriorityHeadTag = (tag: object): boolean =>
|
|
239
|
+
priorityTags.has(tag);
|
|
240
|
+
|
|
241
|
+
export const headTagsToComponents = <Element>(
|
|
242
|
+
react: ElementFactory<Element>,
|
|
243
|
+
tagName: string,
|
|
244
|
+
tags: HeadTag[],
|
|
245
|
+
): Element[] => {
|
|
246
|
+
const createElement = createElementFactory(react);
|
|
247
|
+
return tags
|
|
248
|
+
.filter(tag => !priorityTags.has(tag))
|
|
249
|
+
.map((tag, index) => createElement(tagName, toReactProps(tag, index)));
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const headTitleToComponent = <Element>(
|
|
253
|
+
react: ElementFactory<Element>,
|
|
254
|
+
title: string | undefined,
|
|
255
|
+
attributes: HeadTag,
|
|
256
|
+
): Element[] => {
|
|
257
|
+
if (!title) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
const createElement = createElementFactory(react);
|
|
261
|
+
return [createElement('title', toReactProps(attributes, title), title)];
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const normalizedEntries = (tag: HeadTag) =>
|
|
265
|
+
Object.entries(tag).map(([name, value]) => [
|
|
266
|
+
toHtmlAttributeName(name).toLowerCase(),
|
|
267
|
+
String(value).toLowerCase(),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
const isSeoPriorityTag = (tagName: PriorityTagName, tag: HeadTag): boolean => {
|
|
271
|
+
const values = SEO_PRIORITY_VALUES[tagName];
|
|
272
|
+
return normalizedEntries(tag).some(([name, value]) => {
|
|
273
|
+
const allowed = values[name];
|
|
274
|
+
return (
|
|
275
|
+
allowed !== undefined && (name === 'charset' || allowed.includes(value))
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const createPriorityHeadDatum = <Element, RecordType>(
|
|
281
|
+
react: ElementFactory<Element>,
|
|
282
|
+
records: RecordType[],
|
|
283
|
+
dedupe: (tagName: PriorityTagName, records: RecordType[]) => HeadTag[],
|
|
284
|
+
): HeadDatum<Element> => {
|
|
285
|
+
if (
|
|
286
|
+
!records.some(
|
|
287
|
+
record =>
|
|
288
|
+
typeof record === 'object' &&
|
|
289
|
+
record !== null &&
|
|
290
|
+
priorityRecords.has(record),
|
|
291
|
+
)
|
|
292
|
+
) {
|
|
293
|
+
return { toComponent: () => [], toString: () => '' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const groups = (['meta', 'link', 'script'] as const).map(tagName => ({
|
|
297
|
+
tagName,
|
|
298
|
+
tags: dedupe(tagName, records).filter(tag =>
|
|
299
|
+
isSeoPriorityTag(tagName, tag),
|
|
300
|
+
),
|
|
301
|
+
}));
|
|
302
|
+
for (const { tags } of groups) {
|
|
303
|
+
for (const tag of tags) {
|
|
304
|
+
priorityTags.add(tag);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const createElement = createElementFactory(react);
|
|
309
|
+
return {
|
|
310
|
+
toComponent: () =>
|
|
311
|
+
groups.flatMap(({ tagName, tags }) =>
|
|
312
|
+
tags.map((tag, index) =>
|
|
313
|
+
createElement(tagName, toReactProps(tag, `${tagName}-${index}`)),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
toString: () =>
|
|
317
|
+
groups.map(({ tagName, tags }) => tagsToString(tagName, tags)).join(''),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const createDatum = <Element>(
|
|
322
|
+
react: ElementFactory<Element>,
|
|
323
|
+
tagName: HelmetTagName,
|
|
324
|
+
tags: HeadTag[],
|
|
325
|
+
): HeadDatum<Element> => ({
|
|
326
|
+
toComponent: () => headTagsToComponents(react, tagName, tags),
|
|
327
|
+
toString: () =>
|
|
328
|
+
tagsToString(
|
|
329
|
+
tagName,
|
|
330
|
+
tags.filter(tag => !priorityTags.has(tag)),
|
|
331
|
+
),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const createAttributeDatum = (attributes: HeadTag): HeadAttributeDatum => ({
|
|
335
|
+
toComponent: () => toReactAttributes(attributes),
|
|
336
|
+
toString: () => attributesToString(attributes),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const createTitleDatum = <Element>(
|
|
340
|
+
react: ElementFactory<Element>,
|
|
341
|
+
title: string | undefined,
|
|
342
|
+
attributes: HeadTag,
|
|
343
|
+
): HeadDatum<Element> => ({
|
|
344
|
+
toComponent: () => headTitleToComponent(react, title, attributes),
|
|
345
|
+
toString: () => {
|
|
346
|
+
if (!title) {
|
|
347
|
+
return '';
|
|
348
|
+
}
|
|
349
|
+
const serialized = attributesToString(attributes, true);
|
|
350
|
+
return `<title ${serialized}>${escapeHtml(title)}</title>`;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const normalizeHelmetTitle = (title: unknown): string | undefined => {
|
|
355
|
+
if (typeof title === 'string') {
|
|
356
|
+
return title;
|
|
357
|
+
}
|
|
358
|
+
if (Array.isArray(title)) {
|
|
359
|
+
return title.map(part => String(part)).join('');
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const mergeAttributes = (
|
|
365
|
+
current: HeadTag,
|
|
366
|
+
next: HeadTag | undefined,
|
|
367
|
+
): HeadTag => ({ ...current, ...(next ?? {}) });
|
|
368
|
+
|
|
369
|
+
const isHeadElement = <Child>(value: unknown): value is HeadElement<Child> =>
|
|
370
|
+
typeof value === 'object' &&
|
|
371
|
+
value !== null &&
|
|
372
|
+
'type' in value &&
|
|
373
|
+
'props' in value &&
|
|
374
|
+
typeof value.props === 'object' &&
|
|
375
|
+
value.props !== null;
|
|
376
|
+
|
|
377
|
+
const collectChildren = <Element, Child>(
|
|
378
|
+
react: ReactAdapter<Element, Child>,
|
|
379
|
+
children: Child | undefined,
|
|
380
|
+
draft: Omit<
|
|
381
|
+
HelmetCompatRecord,
|
|
382
|
+
'defaultTitle' | 'prioritizeSeoTags' | 'titleTemplate'
|
|
383
|
+
>,
|
|
384
|
+
): void => {
|
|
385
|
+
react.Children.forEach(children, child => {
|
|
386
|
+
if (!react.isValidElement(child) || !isHeadElement<Child>(child)) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const element = child;
|
|
390
|
+
if (element.type === react.Fragment) {
|
|
391
|
+
collectChildren(react, element.props.children, draft);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (typeof element.type !== 'string') {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const { children: nestedChildren, ...props } = element.props;
|
|
399
|
+
if (element.type === 'title') {
|
|
400
|
+
draft.title = react.Children.toArray(nestedChildren).join('');
|
|
401
|
+
draft.titleAttributes = mergeAttributes(draft.titleAttributes, props);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (element.type === 'html') {
|
|
405
|
+
draft.htmlAttributes = mergeAttributes(draft.htmlAttributes ?? {}, props);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (element.type === 'body') {
|
|
409
|
+
draft.bodyAttributes = mergeAttributes(draft.bodyAttributes ?? {}, props);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
element.type === 'base' ||
|
|
414
|
+
element.type === 'link' ||
|
|
415
|
+
element.type === 'meta' ||
|
|
416
|
+
element.type === 'noscript' ||
|
|
417
|
+
element.type === 'script' ||
|
|
418
|
+
element.type === 'style'
|
|
419
|
+
) {
|
|
420
|
+
const tag = { ...props };
|
|
421
|
+
if (
|
|
422
|
+
(element.type === 'script' ||
|
|
423
|
+
element.type === 'style' ||
|
|
424
|
+
element.type === 'noscript') &&
|
|
425
|
+
typeof nestedChildren === 'string'
|
|
426
|
+
) {
|
|
427
|
+
tag[element.type === 'style' ? 'cssText' : 'innerHTML'] =
|
|
428
|
+
nestedChildren;
|
|
429
|
+
}
|
|
430
|
+
draft[element.type].push(tag);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export const createHelmetRecord = <Element, Child>(
|
|
436
|
+
reactValue: ReactAdapter<Element, Child>,
|
|
437
|
+
props: HelmetCompatProps<Child>,
|
|
438
|
+
): HelmetCompatRecord => {
|
|
439
|
+
const draft: Omit<
|
|
440
|
+
HelmetCompatRecord,
|
|
441
|
+
'defaultTitle' | 'prioritizeSeoTags' | 'titleTemplate'
|
|
442
|
+
> = {
|
|
443
|
+
base: props.base === undefined ? [] : [{ ...props.base }],
|
|
444
|
+
bodyAttributes:
|
|
445
|
+
props.bodyAttributes === undefined
|
|
446
|
+
? undefined
|
|
447
|
+
: { ...props.bodyAttributes },
|
|
448
|
+
htmlAttributes:
|
|
449
|
+
props.htmlAttributes === undefined
|
|
450
|
+
? undefined
|
|
451
|
+
: { ...props.htmlAttributes },
|
|
452
|
+
link: (props.link ?? []).map(tag => ({ ...tag })),
|
|
453
|
+
meta: (props.meta ?? []).map(tag => ({ ...tag })),
|
|
454
|
+
noscript: (props.noscript ?? []).map(tag => ({ ...tag })),
|
|
455
|
+
script: (props.script ?? []).map(tag => ({ ...tag })),
|
|
456
|
+
style: (props.style ?? []).map(tag => ({ ...tag })),
|
|
457
|
+
title: normalizeHelmetTitle(props.title),
|
|
458
|
+
titleAttributes: { ...(props.titleAttributes ?? {}) },
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
collectChildren(reactValue, props.children, draft);
|
|
462
|
+
const record: HelmetCompatRecord = {
|
|
463
|
+
...draft,
|
|
464
|
+
defaultTitle: props.defaultTitle,
|
|
465
|
+
prioritizeSeoTags: props.prioritizeSeoTags === true,
|
|
466
|
+
titleTemplate: props.titleTemplate,
|
|
467
|
+
};
|
|
468
|
+
markPriorityHeadRecord(record, record.prioritizeSeoTags);
|
|
469
|
+
return record;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const getInnermostDefined = <T>(
|
|
473
|
+
records: HelmetCompatRecord[],
|
|
474
|
+
pick: (record: HelmetCompatRecord) => T | undefined,
|
|
475
|
+
): T | undefined => {
|
|
476
|
+
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
477
|
+
const value = pick(records[index]);
|
|
478
|
+
if (value !== undefined) {
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return undefined;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const deriveTitle = (records: HelmetCompatRecord[]): string | undefined => {
|
|
486
|
+
const title = getInnermostDefined(records, record => record.title);
|
|
487
|
+
const template = getInnermostDefined(records, record => record.titleTemplate);
|
|
488
|
+
if (template && title) {
|
|
489
|
+
return template.replaceAll('%s', title);
|
|
490
|
+
}
|
|
491
|
+
return title || getInnermostDefined(records, record => record.defaultTitle);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const mergeRecordAttributes = (
|
|
495
|
+
records: HelmetCompatRecord[],
|
|
496
|
+
pick: (record: HelmetCompatRecord) => HeadTag | undefined,
|
|
497
|
+
): HeadTag =>
|
|
498
|
+
records.reduce<HeadTag>(
|
|
499
|
+
(merged, record) => mergeAttributes(merged, pick(record)),
|
|
500
|
+
{},
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const deriveBaseTags = (records: HelmetCompatRecord[]): HeadTag[] => {
|
|
504
|
+
for (let recordIndex = records.length - 1; recordIndex >= 0; recordIndex--) {
|
|
505
|
+
const tags = records[recordIndex].base;
|
|
506
|
+
for (let tagIndex = tags.length - 1; tagIndex >= 0; tagIndex--) {
|
|
507
|
+
if (tags[tagIndex].href) {
|
|
508
|
+
return [tags[tagIndex]];
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return [];
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const getPrimaryAttribute = (
|
|
516
|
+
tagName: DedupableTagName,
|
|
517
|
+
tag: HeadTag,
|
|
518
|
+
): { key: string; value: string } | undefined => {
|
|
519
|
+
const candidates = TAG_PRIMARY_ATTRIBUTES[tagName];
|
|
520
|
+
let selectedKey: string | undefined;
|
|
521
|
+
let selectedValue: unknown;
|
|
522
|
+
|
|
523
|
+
for (const [key, value] of Object.entries(tag)) {
|
|
524
|
+
const normalizedKey = CONTENT_PROPERTIES.has(key)
|
|
525
|
+
? key
|
|
526
|
+
: toHtmlAttributeName(key).toLowerCase();
|
|
527
|
+
if (!candidates.includes(normalizedKey)) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (
|
|
531
|
+
selectedKey === 'rel' &&
|
|
532
|
+
String(selectedValue).toLowerCase() === 'canonical'
|
|
533
|
+
) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (
|
|
537
|
+
normalizedKey === 'rel' &&
|
|
538
|
+
String(value).toLowerCase() === 'stylesheet'
|
|
539
|
+
) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
selectedKey = normalizedKey;
|
|
543
|
+
selectedValue = value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (selectedKey === undefined || !selectedValue) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
return { key: selectedKey, value: String(selectedValue).toLowerCase() };
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const dedupeTags = (
|
|
553
|
+
tagName: DedupableTagName,
|
|
554
|
+
records: HelmetCompatRecord[],
|
|
555
|
+
): HeadTag[] => {
|
|
556
|
+
const approvedValues = new Map<string, Set<string>>();
|
|
557
|
+
const approved: HeadTag[] = [];
|
|
558
|
+
|
|
559
|
+
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
560
|
+
const instanceValues = new Map<string, Set<string>>();
|
|
561
|
+
const kept: HeadTag[] = [];
|
|
562
|
+
for (const tag of records[index][tagName]) {
|
|
563
|
+
const primary = getPrimaryAttribute(tagName, tag);
|
|
564
|
+
if (
|
|
565
|
+
primary === undefined ||
|
|
566
|
+
approvedValues.get(primary.key)?.has(primary.value)
|
|
567
|
+
) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const seen = instanceValues.get(primary.key) ?? new Set<string>();
|
|
571
|
+
seen.add(primary.value);
|
|
572
|
+
instanceValues.set(primary.key, seen);
|
|
573
|
+
kept.push(tag);
|
|
574
|
+
}
|
|
575
|
+
approved.unshift(...kept);
|
|
576
|
+
for (const [key, values] of instanceValues) {
|
|
577
|
+
const target = approvedValues.get(key);
|
|
578
|
+
if (target === undefined) {
|
|
579
|
+
approvedValues.set(key, values);
|
|
580
|
+
} else {
|
|
581
|
+
for (const value of values) {
|
|
582
|
+
target.add(value);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return approved;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
export const deriveHelmetServerState = <Element>(
|
|
591
|
+
react: ElementFactory<Element>,
|
|
592
|
+
records: HelmetCompatRecord[],
|
|
593
|
+
): HelmetCompatState<Element> => {
|
|
594
|
+
const priority = createPriorityHeadDatum(react, records, dedupeTags);
|
|
595
|
+
return {
|
|
596
|
+
base: createDatum(react, 'base', deriveBaseTags(records)),
|
|
597
|
+
bodyAttributes: createAttributeDatum(
|
|
598
|
+
mergeRecordAttributes(records, record => record.bodyAttributes),
|
|
599
|
+
),
|
|
600
|
+
htmlAttributes: createAttributeDatum(
|
|
601
|
+
mergeRecordAttributes(records, record => record.htmlAttributes),
|
|
602
|
+
),
|
|
603
|
+
link: createDatum(react, 'link', dedupeTags('link', records)),
|
|
604
|
+
meta: createDatum(react, 'meta', dedupeTags('meta', records)),
|
|
605
|
+
noscript: createDatum(react, 'noscript', dedupeTags('noscript', records)),
|
|
606
|
+
priority,
|
|
607
|
+
script: createDatum(react, 'script', dedupeTags('script', records)),
|
|
608
|
+
style: createDatum(react, 'style', dedupeTags('style', records)),
|
|
609
|
+
title: createTitleDatum(
|
|
610
|
+
react,
|
|
611
|
+
deriveTitle(records),
|
|
612
|
+
mergeRecordAttributes(records, record => record.titleAttributes),
|
|
613
|
+
),
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
export const collectImmediateHelmetState = <Element>(
|
|
618
|
+
react: ElementFactory<Element>,
|
|
619
|
+
context: object,
|
|
620
|
+
record: HelmetCompatRecord,
|
|
621
|
+
): HelmetCompatState<Element> => {
|
|
622
|
+
const records = immediateRecords.get(context) ?? [];
|
|
623
|
+
if (!immediateRecords.has(context)) {
|
|
624
|
+
immediateRecords.set(context, records);
|
|
625
|
+
}
|
|
626
|
+
records.push(record);
|
|
627
|
+
return deriveHelmetServerState(react, records);
|
|
628
|
+
};
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const SAFE_HTML_ATTRIBUTE_NAME = /^[^\u0000-\u0020"'<>/=]+$/u;
|
|
2
|
+
|
|
3
|
+
export const isSafeHtmlAttributeName = (name: string): boolean =>
|
|
4
|
+
SAFE_HTML_ATTRIBUTE_NAME.test(name);
|
|
5
|
+
|
|
6
|
+
export const escapeHtmlAttribute = (value: unknown): string =>
|
|
7
|
+
String(value)
|
|
8
|
+
.replaceAll('&', '&')
|
|
9
|
+
.replaceAll('"', '"')
|
|
10
|
+
.replaceAll('<', '<')
|
|
11
|
+
.replaceAll('>', '>');
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type ChunkLoaderIdentity = object;
|
|
2
|
+
|
|
3
|
+
const realmNamespaces = new WeakMap<
|
|
4
|
+
object,
|
|
5
|
+
WeakMap<ChunkLoaderIdentity, Map<string, string>>
|
|
6
|
+
>();
|
|
7
|
+
let nextRealmNamespace = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Namespaces route warmups by the identities that give a chunk id meaning.
|
|
11
|
+
* Weak maps ensure a retired app context or bundler runtime is not retained by
|
|
12
|
+
* the shared prefetch cache. The public path is the final, value-based scope.
|
|
13
|
+
*/
|
|
14
|
+
export function getNavigationWarmupCacheKey(
|
|
15
|
+
runtimeContext: object,
|
|
16
|
+
chunkLoader: ChunkLoaderIdentity,
|
|
17
|
+
publicPath: string,
|
|
18
|
+
warmupKey: string,
|
|
19
|
+
): string {
|
|
20
|
+
let loaderNamespaces = realmNamespaces.get(runtimeContext);
|
|
21
|
+
if (loaderNamespaces === undefined) {
|
|
22
|
+
loaderNamespaces = new WeakMap();
|
|
23
|
+
realmNamespaces.set(runtimeContext, loaderNamespaces);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let publicPathNamespaces = loaderNamespaces.get(chunkLoader);
|
|
27
|
+
if (publicPathNamespaces === undefined) {
|
|
28
|
+
publicPathNamespaces = new Map();
|
|
29
|
+
loaderNamespaces.set(chunkLoader, publicPathNamespaces);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let namespace = publicPathNamespaces.get(publicPath);
|
|
33
|
+
if (namespace === undefined) {
|
|
34
|
+
namespace = `realm-${++nextRealmNamespace}`;
|
|
35
|
+
publicPathNamespaces.set(publicPath, namespace);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${namespace}:${warmupKey}`;
|
|
39
|
+
}
|