@tinvar/renderer 0.0.0-stage → 0.4.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/src/head.js ADDED
@@ -0,0 +1,524 @@
1
+ // One title, and one meta tag and one link of each key, in the document's
2
+ // head.
3
+ //
4
+ // tinvar-spec.md, "Operations and tooling": "React 19 hoists <title> and
5
+ // <meta> from components; add key-based dedupe, one title, a global title
6
+ // template, and PHP-rendered fallback tags marked for replacement."
7
+ //
8
+ // React writes every hoisted tag into <head> as the shell flushes. The
9
+ // components in @tinvar/react/head mark each with its key and its depth in
10
+ // the route; this keeps, for each key, the deepest -- the last among equals,
11
+ // as React rendered them -- and writes PHP's fallback tags first, marked,
12
+ // for any of them to replace. Only the head is held to do it, never the
13
+ // body: the shell is flushed whole, so the head is never waiting on more
14
+ // than the rest of its own flush.
15
+ //
16
+ // A link <HeadLink> marked takes part by its key. One nothing marked -- a
17
+ // raw <link> a component or the document wrote -- takes part at the
18
+ // document's depth, as a raw <title> does, keyed as <HeadLink> would key
19
+ // it, unless it is one of React's own resources: a stylesheet, a preload,
20
+ // a module preload, a preconnect, a DNS prefetch or a prefetch. Those React
21
+ // writes for itself, dedupes by href itself and finds again by href when it
22
+ // hydrates, so they are left exactly as written.
23
+ //
24
+ // Only here, on the server, where a crawler reads the head. In the browser
25
+ // a raw link is React's to hoist and to remove; one dropped here is put
26
+ // back as the page hydrates, and a page a navigation mounts adds its own
27
+ // beside the layout's. A link that changes with the page belongs in a
28
+ // <HeadLink>, which agrees with the others in the browser too.
29
+
30
+ const encoder = new TextEncoder();
31
+ const decoder = new TextDecoder();
32
+
33
+ /** Mirrors @tinvar/react/head and Tinvar\Protocol\HeadTag. */
34
+ const FALLBACK = 'data-tinvar-head="fallback"';
35
+ const KEY_ATTRIBUTE = 'data-tinvar-key';
36
+ const DEPTH_ATTRIBUTE = 'data-tinvar-depth';
37
+
38
+ /** How much of a document is held looking for the end of its head before it is sent as it is. */
39
+ const MAX_HEAD_BYTES = 512 * 1024;
40
+
41
+ /** The most fallback tags one render writes; PHP sends a handful. */
42
+ const MAX_FALLBACKS = 32;
43
+
44
+ const END_OF_HEAD = encoder.encode('</head>');
45
+
46
+ /** @param {string} value */
47
+ function escape(value) {
48
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49
+ }
50
+
51
+ /**
52
+ * PHP's fallback tags, as markup marked for replacement. Only a title or a
53
+ * meta tag, only attribute names of an attribute's shape and never a
54
+ * handler, every value escaped: the frame is PHP's, but what reaches the
55
+ * document is decided where the markup is written.
56
+ *
57
+ * @param {unknown} head the render frame's `head`
58
+ * @returns {string}
59
+ */
60
+ export function fallbackTags(head) {
61
+ if (!Array.isArray(head)) return '';
62
+ let out = '';
63
+ for (const entry of head.slice(0, MAX_FALLBACKS)) {
64
+ if (entry === null || typeof entry !== 'object') continue;
65
+ if (entry.tag === 'title' && typeof entry.text === 'string') {
66
+ out += `<title ${FALLBACK}>${escape(entry.text)}</title>`;
67
+ continue;
68
+ }
69
+ if (entry.tag !== 'meta' || entry.attributes === null || typeof entry.attributes !== 'object') continue;
70
+ let attributes = '';
71
+ for (const [name, value] of Object.entries(entry.attributes)) {
72
+ if (!/^[a-z][a-z0-9:_.-]*$/.test(name) || name.startsWith('on') || typeof value !== 'string') continue;
73
+ attributes += ` ${name}="${escape(value)}"`;
74
+ }
75
+ out += `<meta${attributes} ${FALLBACK}>`;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** @param {string} tag the opening tag @returns {Record<string, string>} */
81
+ function attributesOf(tag) {
82
+ const attributes = {};
83
+ const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
84
+ // Past the tag's name.
85
+ const body = tag.replace(/^<\s*[a-zA-Z]+/, '').replace(/\/?>$/, '');
86
+ for (const match of body.matchAll(pattern)) {
87
+ attributes[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
88
+ }
89
+ return attributes;
90
+ }
91
+
92
+ /**
93
+ * Rels of the links React writes for its own resources, which a raw link
94
+ * of any of them is taken to be. Mirrors nothing in @tinvar/react/head:
95
+ * <HeadLink> renders a stylesheet unkeyed for the same reason.
96
+ */
97
+ const RESOURCE_RELS = new Set([
98
+ 'stylesheet',
99
+ 'preload',
100
+ 'modulepreload',
101
+ 'preconnect',
102
+ 'dns-prefetch',
103
+ 'prefetch',
104
+ ]);
105
+
106
+ /** Rels whose links are the page's icon at a size, as @tinvar/react/head's linkKey() has them. */
107
+ const ICON_RELS = new Set([
108
+ 'icon',
109
+ 'shortcut icon',
110
+ 'apple-touch-icon',
111
+ 'apple-touch-icon-precomposed',
112
+ 'mask-icon',
113
+ ]);
114
+
115
+ /**
116
+ * The key a raw link is deduplicated by: @tinvar/react/head's linkKey() for
117
+ * the same attributes, so a raw canonical and a <HeadLink> canonical are
118
+ * one key. Null for a link that takes no part: one of React's resources
119
+ * (any rel naming one, `alternate stylesheet` too), or one with nothing to
120
+ * key it by.
121
+ *
122
+ * @param {Record<string, string>} attributes
123
+ * @returns {string | null}
124
+ */
125
+ export function rawLinkKey(attributes) {
126
+ const rel = (attributes.rel ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
127
+ if (rel === '' || rel.split(' ').some((token) => RESOURCE_RELS.has(token))) return null;
128
+ if (rel === 'canonical') return 'link:canonical';
129
+
130
+ const hrefLang = attributes.hreflang ?? '';
131
+ if (rel === 'alternate' && hrefLang !== '') return `link:alternate:${hrefLang.toLowerCase()}`;
132
+ if (ICON_RELS.has(rel)) {
133
+ const sizes = (attributes.sizes ?? '').trim().toLowerCase();
134
+ const media = (attributes.media ?? '').trim();
135
+ const type = (attributes.type ?? '').trim().toLowerCase();
136
+ return `link:${rel}:${sizes}:${media}:${type}`;
137
+ }
138
+ // The href as React wrote it, escaped as it escapes a marked link's key.
139
+ return attributes.href ? `link:${rel}:${attributes.href}` : null;
140
+ }
141
+
142
+ /**
143
+ * The key a head tag is deduplicated by, and how deep in the route it was
144
+ * rendered: a fallback below everything, a tag no component marked -- the
145
+ * document's own, a bare <title>, a raw link -- at the document's depth, 0.
146
+ *
147
+ * @returns {{ key: string, depth: number } | null}
148
+ */
149
+ function identify(name, attributes) {
150
+ const fallback = attributes['data-tinvar-head'] === 'fallback';
151
+ const depth = fallback ? -Infinity : Number(attributes[DEPTH_ATTRIBUTE] ?? 0) || 0;
152
+ if (name === 'title') return { key: 'title', depth };
153
+ if (attributes[KEY_ATTRIBUTE]) return { key: attributes[KEY_ATTRIBUTE], depth };
154
+ if (name === 'link') {
155
+ const key = rawLinkKey(attributes);
156
+ return key === null ? null : { key, depth: 0 };
157
+ }
158
+ if (attributes.name !== undefined) return { key: `name:${attributes.name}`, depth };
159
+ if (attributes.property !== undefined) return { key: `property:${attributes.property}`, depth };
160
+ if (attributes['http-equiv'] !== undefined) {
161
+ return { key: `http-equiv:${attributes['http-equiv'].toLowerCase()}`, depth };
162
+ }
163
+ if (attributes.charset !== undefined) return { key: 'charset', depth };
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Elements whose body is text, never tags: raw text and RCDATA, as a
169
+ * browser with scripting reads them, and plaintext, whose body is the rest
170
+ * of the document.
171
+ */
172
+ const TEXT_BODIES = new Set([
173
+ 'script',
174
+ 'style',
175
+ 'noscript',
176
+ 'textarea',
177
+ 'title',
178
+ 'xmp',
179
+ 'iframe',
180
+ 'noembed',
181
+ 'noframes',
182
+ 'plaintext',
183
+ ]);
184
+
185
+ /** Elements with no end tag, which leave the head's top level where it was. */
186
+ const VOID = new Set([
187
+ 'area',
188
+ 'base',
189
+ 'br',
190
+ 'col',
191
+ 'embed',
192
+ 'hr',
193
+ 'img',
194
+ 'input',
195
+ 'keygen',
196
+ 'link',
197
+ 'meta',
198
+ 'param',
199
+ 'source',
200
+ 'track',
201
+ 'wbr',
202
+ ]);
203
+
204
+ const WHITESPACE = /[\t\n\f\r ]/;
205
+
206
+ /**
207
+ * Lower case as HTML has it: ASCII letters only. String#toLowerCase()
208
+ * lowers others too -- the Kelvin sign to a `k`, `İ` to two code units --
209
+ * and would read as `link` a name no browser does.
210
+ *
211
+ * @param {string} value
212
+ */
213
+ function asciiLower(value) {
214
+ return value.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
215
+ }
216
+
217
+ /**
218
+ * A tag from just past its name, as the HTML tokenizer reads it: to the
219
+ * first `>` outside a quoted attribute value. Null if the head ends first.
220
+ *
221
+ * @param {string} html
222
+ * @param {number} at just past the tag's name
223
+ * @returns {{ attributes: Record<string, string>, end: number } | null}
224
+ */
225
+ function readTag(html, at) {
226
+ /** @type {Record<string, string>} */
227
+ const attributes = {};
228
+ let i = at;
229
+ for (;;) {
230
+ while (i < html.length && (WHITESPACE.test(html[i]) || html[i] === '/')) i++;
231
+ if (i >= html.length) return null;
232
+ if (html[i] === '>') return { attributes, end: i + 1 };
233
+
234
+ const nameStart = i++;
235
+ while (i < html.length && !WHITESPACE.test(html[i]) && !'/>='.includes(html[i])) i++;
236
+ const name = asciiLower(html.slice(nameStart, i));
237
+ while (i < html.length && WHITESPACE.test(html[i])) i++;
238
+ let value = '';
239
+ if (html[i] === '=') {
240
+ i++;
241
+ while (i < html.length && WHITESPACE.test(html[i])) i++;
242
+ const quote = html[i];
243
+ if (quote === '"' || quote === "'") {
244
+ const close = html.indexOf(quote, i + 1);
245
+ if (close === -1) return null;
246
+ value = html.slice(i + 1, close);
247
+ i = close + 1;
248
+ } else {
249
+ const valueStart = i;
250
+ while (i < html.length && !WHITESPACE.test(html[i]) && html[i] !== '>') i++;
251
+ value = html.slice(valueStart, i);
252
+ }
253
+ }
254
+ // The first of two attributes of one name is the one a browser keeps.
255
+ if (!Object.hasOwn(attributes, name)) attributes[name] = value;
256
+ }
257
+ }
258
+
259
+ /** Where a tag's name ends: at whitespace, a `/` or a `>`. */
260
+ function endOfName(html, at) {
261
+ let i = at;
262
+ while (i < html.length && !WHITESPACE.test(html[i]) && !'/>'.includes(html[i])) i++;
263
+ return i;
264
+ }
265
+
266
+ /**
267
+ * Where the end tag that closes a text body ends: the first `</name`, in
268
+ * any ASCII case, followed by what can end a tag's name. Matched in the
269
+ * head itself, never a lowered copy, whose indices need not be the head's.
270
+ * -1 if the body runs to the end of the head.
271
+ *
272
+ * @param {string} html
273
+ * @param {string} name a name of TEXT_BODIES
274
+ * @param {number} from where the body starts
275
+ */
276
+ function endOfTextBody(html, name, from) {
277
+ // No `u` flag: case-insensitive then folds no character outside ASCII
278
+ // into one inside it, as HTML's ASCII case-insensitivity does not.
279
+ const end = new RegExp(`</${name}(?=[\\t\\n\\f\\r />]|$)`, 'gi');
280
+ end.lastIndex = from;
281
+ const match = end.exec(html);
282
+ if (match === null) return -1;
283
+ return readTag(html, match.index + match[0].length)?.end ?? -1;
284
+ }
285
+
286
+ /**
287
+ * The titles, metas and links at the head's top level, each whole, as a
288
+ * browser tokenises the head: never one inside a comment, an attribute's
289
+ * value, the text body of a style, a script or the like, or an element
290
+ * still open around it.
291
+ *
292
+ * Null wherever this reading could differ from a browser's, and the head
293
+ * is then left exactly as it came: cutting a tag out of a head read wrongly
294
+ * can put the text either side of it together into markup. So a script
295
+ * whose body holds `<!--` (its escaped states, where `</script>` need not
296
+ * end it), svg or math (foreign content, whose style is no text body), a
297
+ * text body, comment or tag the head ends inside, plaintext, and a bare
298
+ * `<`, which React never writes.
299
+ *
300
+ * @param {string} html what is between <head> and </head>
301
+ * @returns {{ name: string, attributes: Record<string, string>, start: number, end: number }[] | null}
302
+ */
303
+ function topLevelTags(html) {
304
+ const tags = [];
305
+ let depth = 0;
306
+ let i = 0;
307
+ while (i < html.length) {
308
+ const start = html.indexOf('<', i);
309
+ if (start === -1) break;
310
+ const next = html[start + 1] ?? '';
311
+
312
+ if (html.startsWith('<!--', start)) {
313
+ if (html.startsWith('>', start + 4)) i = start + 5;
314
+ else if (html.startsWith('->', start + 4)) i = start + 6;
315
+ else {
316
+ const close = /--!?>/g;
317
+ close.lastIndex = start + 4;
318
+ const match = close.exec(html);
319
+ if (match === null) return null;
320
+ i = match.index + match[0].length;
321
+ }
322
+ continue;
323
+ }
324
+ if (next === '!' || next === '?' || (next === '/' && !/[a-zA-Z>]/.test(html[start + 2] ?? ''))) {
325
+ // A bogus comment, to the first `>`.
326
+ const close = html.indexOf('>', start + 2);
327
+ if (close === -1) return null;
328
+ i = close + 1;
329
+ continue;
330
+ }
331
+ if (next === '/') {
332
+ if (html[start + 2] === '>') {
333
+ i = start + 3;
334
+ continue;
335
+ }
336
+ const nameEnd = endOfName(html, start + 2);
337
+ const tag = readTag(html, nameEnd);
338
+ if (tag === null) return null;
339
+ if (!VOID.has(asciiLower(html.slice(start + 2, nameEnd)))) depth = Math.max(0, depth - 1);
340
+ i = tag.end;
341
+ continue;
342
+ }
343
+ if (!/[a-zA-Z]/.test(next)) return null;
344
+
345
+ const nameEnd = endOfName(html, start + 1);
346
+ const name = asciiLower(html.slice(start + 1, nameEnd));
347
+ const tag = readTag(html, nameEnd);
348
+ if (tag === null || name === 'plaintext' || name === 'svg' || name === 'math') return null;
349
+
350
+ if (TEXT_BODIES.has(name)) {
351
+ const end = endOfTextBody(html, name, tag.end);
352
+ if (end === -1) return null;
353
+ if (name === 'script' && html.slice(tag.end, end).includes('<!--')) return null;
354
+ if (name === 'title' && depth === 0) tags.push({ name, attributes: tag.attributes, start, end });
355
+ i = end;
356
+ continue;
357
+ }
358
+ if (name === 'meta' || name === 'link') {
359
+ if (depth === 0) tags.push({ name, attributes: tag.attributes, start, end: tag.end });
360
+ } else if (!VOID.has(name)) {
361
+ depth++;
362
+ }
363
+ i = tag.end;
364
+ }
365
+ return tags;
366
+ }
367
+
368
+ /**
369
+ * The head's markup with one tag of each key: the deepest, the last among
370
+ * equals. A head read with any doubt (topLevelTags()) is left as it came.
371
+ *
372
+ * @param {string} head what is between <head> and </head>
373
+ * @returns {string}
374
+ */
375
+ export function dedupeHead(head) {
376
+ const found = topLevelTags(head);
377
+ if (found === null) return head;
378
+ const tags = [];
379
+ for (const tag of found) {
380
+ const id = identify(tag.name, tag.attributes);
381
+ if (id !== null) tags.push({ ...id, start: tag.start, end: tag.end });
382
+ }
383
+
384
+ /** @type {Map<string, typeof tags[number]>} */
385
+ const winners = new Map();
386
+ for (const tag of tags) {
387
+ const best = winners.get(tag.key);
388
+ if (best === undefined || tag.depth >= best.depth) winners.set(tag.key, tag);
389
+ }
390
+
391
+ let out = '';
392
+ let at = 0;
393
+ for (const tag of tags) {
394
+ if (winners.get(tag.key) === tag) continue;
395
+ out += head.slice(at, tag.start);
396
+ at = tag.end;
397
+ }
398
+ return out + head.slice(at);
399
+ }
400
+
401
+ /**
402
+ * The document with PHP's fallbacks first in its head, and one tag of
403
+ * each key there.
404
+ *
405
+ * @param {string} document the document up to and including `</head>`
406
+ * @param {string} fallbacks from fallbackTags()
407
+ */
408
+ export function resolveHead(document, fallbacks) {
409
+ const open = /<head(?:\s[^>]*)?>/i.exec(document);
410
+ const close = asciiLower(document).lastIndexOf('</head>');
411
+ if (open === null || close < open.index) return document;
412
+
413
+ // After the charset, when the head starts with it: a browser looks for
414
+ // the encoding in the document's first bytes, so nothing goes ahead of it.
415
+ const start = open.index + open[0].length;
416
+ const charset = /^<meta\s+charset\s*=[^>]*>/i.exec(document.slice(start, close));
417
+ const at = start + (charset ? charset[0].length : 0);
418
+ return document.slice(0, at) + dedupeHead(fallbacks + document.slice(at, close)) + document.slice(close);
419
+ }
420
+
421
+ /** Where `</head>` ends in `bytes`, or -1. ASCII, so found in the bytes, never cutting a character. */
422
+ function endOfHead(bytes) {
423
+ outer: for (let i = 0; i + END_OF_HEAD.length <= bytes.length; i++) {
424
+ for (let j = 0; j < END_OF_HEAD.length; j++) {
425
+ const byte = bytes[i + j] >= 0x41 && bytes[i + j] <= 0x5a ? bytes[i + j] + 0x20 : bytes[i + j];
426
+ if (byte !== END_OF_HEAD[j]) continue outer;
427
+ }
428
+ return i + END_OF_HEAD.length;
429
+ }
430
+ return -1;
431
+ }
432
+
433
+ /**
434
+ * The modules a document's head preloads, in its order: the client entry,
435
+ * then the chunks of the client components the page renders, as React
436
+ * wrote them from the payload's client references.
437
+ *
438
+ * @param {string} document
439
+ * @returns {string[]}
440
+ */
441
+ export function modulePreloads(document) {
442
+ const head = document.slice(0, Math.max(0, asciiLower(document).lastIndexOf('</head>')));
443
+ const out = [];
444
+ for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
445
+ const attributes = attributesOf(match[0]);
446
+ if (attributes.rel?.toLowerCase() === 'modulepreload' && attributes.href) out.push(attributes.href);
447
+ }
448
+ return [...new Set(out)];
449
+ }
450
+
451
+ /**
452
+ * The HTML stream with its head resolved: held until `</head>` arrives,
453
+ * then sent with the fallbacks written and the duplicates dropped, and
454
+ * everything after it passed through untouched.
455
+ *
456
+ * @param {ReadableStream<Uint8Array>} html
457
+ * @param {unknown} head the render frame's `head`
458
+ * @param {(resolved: { preload: string[] }) => void} [onResolved] told, once,
459
+ * what the resolved head preloads -- for PHP's Link header
460
+ * @returns {ReadableStream<Uint8Array>}
461
+ */
462
+ export function withHead(html, head, onResolved) {
463
+ const fallbacks = fallbackTags(head);
464
+ // What is held, as it came: joined once, when `</head>` is found, and
465
+ // searched only where the newest chunk could finish it -- copying and
466
+ // rescanning the whole on every chunk made a long head quadratic.
467
+ /** @type {Uint8Array[]} */
468
+ let held = [];
469
+ let heldBytes = 0;
470
+ let tail = new Uint8Array(0);
471
+ let resolved = false;
472
+
473
+ const join = () => {
474
+ const joined = new Uint8Array(heldBytes);
475
+ let at = 0;
476
+ for (const piece of held) {
477
+ joined.set(piece, at);
478
+ at += piece.length;
479
+ }
480
+ held = [];
481
+ heldBytes = 0;
482
+ return joined;
483
+ };
484
+
485
+ return html.pipeThrough(
486
+ new TransformStream({
487
+ transform(chunk, controller) {
488
+ if (resolved) {
489
+ controller.enqueue(chunk);
490
+ return;
491
+ }
492
+ const window = new Uint8Array(tail.length + chunk.length);
493
+ window.set(tail, 0);
494
+ window.set(chunk, tail.length);
495
+ const windowStart = heldBytes - tail.length;
496
+ held.push(chunk);
497
+ heldBytes += chunk.length;
498
+
499
+ const found = endOfHead(window);
500
+ if (found === -1) {
501
+ tail = window.subarray(Math.max(0, window.length - (END_OF_HEAD.length - 1)));
502
+ // No head in what a head could be: a document of some other
503
+ // shape, sent as it came.
504
+ if (heldBytes > MAX_HEAD_BYTES) {
505
+ controller.enqueue(join());
506
+ resolved = true;
507
+ }
508
+ return;
509
+ }
510
+
511
+ const bytes = join();
512
+ const end = windowStart + found;
513
+ const resolvedHead = resolveHead(decoder.decode(bytes.subarray(0, end)), fallbacks);
514
+ onResolved?.({ preload: modulePreloads(resolvedHead) });
515
+ controller.enqueue(encoder.encode(resolvedHead));
516
+ if (end < bytes.length) controller.enqueue(bytes.subarray(end));
517
+ resolved = true;
518
+ },
519
+ flush(controller) {
520
+ if (heldBytes > 0) controller.enqueue(join());
521
+ },
522
+ }),
523
+ );
524
+ }