unhead 3.0.5 → 3.1.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.
@@ -0,0 +1,513 @@
1
+ const URL_META_KEYS = /* @__PURE__ */ new Set([
2
+ "og:url",
3
+ "og:image",
4
+ "og:image:url",
5
+ "og:image:secure_url",
6
+ "og:video",
7
+ "og:video:url",
8
+ "og:video:secure_url",
9
+ "og:audio",
10
+ "og:audio:url",
11
+ "og:audio:secure_url",
12
+ "twitter:image",
13
+ "twitter:image:src",
14
+ "twitter:player",
15
+ "twitter:player:stream"
16
+ ]);
17
+ const KNOWN_META_PROPERTIES = /* @__PURE__ */ new Set([
18
+ "article:author",
19
+ "article:expiration_time",
20
+ "article:modified_time",
21
+ "article:published_time",
22
+ "article:section",
23
+ "article:tag",
24
+ "book:author",
25
+ "book:isbn",
26
+ "book:release_date",
27
+ "book:tag",
28
+ "fb:app_id",
29
+ "og:audio",
30
+ "og:audio:secure_url",
31
+ "og:audio:type",
32
+ "og:audio:url",
33
+ "og:description",
34
+ "og:determiner",
35
+ "og:image",
36
+ "og:image:alt",
37
+ "og:image:height",
38
+ "og:image:secure_url",
39
+ "og:image:type",
40
+ "og:image:url",
41
+ "og:image:width",
42
+ "og:locale",
43
+ "og:locale:alternate",
44
+ "og:site_name",
45
+ "og:title",
46
+ "og:type",
47
+ "og:url",
48
+ "og:video",
49
+ "og:video:alt",
50
+ "og:video:duration",
51
+ "og:video:height",
52
+ "og:video:secure_url",
53
+ "og:video:type",
54
+ "og:video:url",
55
+ "og:video:width",
56
+ "profile:first_name",
57
+ "profile:gender",
58
+ "profile:last_name",
59
+ "profile:username"
60
+ ]);
61
+ const KNOWN_META_NAMES = /* @__PURE__ */ new Set([
62
+ "apple-itunes-app",
63
+ "apple-mobile-web-app-capable",
64
+ "apple-mobile-web-app-status-bar-style",
65
+ "apple-mobile-web-app-title",
66
+ "application-name",
67
+ "author",
68
+ "color-scheme",
69
+ "creator",
70
+ "description",
71
+ "fb:app_id",
72
+ "fediverse:creator",
73
+ "format-detection",
74
+ "generator",
75
+ "google-site-verification",
76
+ "google",
77
+ "googlebot",
78
+ "keywords",
79
+ "mobile-web-app-capable",
80
+ "msapplication-config",
81
+ "msapplication-tilecolor",
82
+ "msapplication-tileimage",
83
+ "publisher",
84
+ "rating",
85
+ "referrer",
86
+ "robots",
87
+ "theme-color",
88
+ "viewport",
89
+ "twitter:app:id:googleplay",
90
+ "twitter:app:id:ipad",
91
+ "twitter:app:id:iphone",
92
+ "twitter:app:name:googleplay",
93
+ "twitter:app:name:ipad",
94
+ "twitter:app:name:iphone",
95
+ "twitter:app:url:googleplay",
96
+ "twitter:app:url:ipad",
97
+ "twitter:app:url:iphone",
98
+ "twitter:card",
99
+ "twitter:creator",
100
+ "twitter:creator:id",
101
+ "twitter:data:1",
102
+ "twitter:data:2",
103
+ "twitter:description",
104
+ "twitter:image",
105
+ "twitter:image:alt",
106
+ "twitter:label:1",
107
+ "twitter:label:2",
108
+ "twitter:player",
109
+ "twitter:player:height",
110
+ "twitter:player:stream",
111
+ "twitter:player:width",
112
+ "twitter:site",
113
+ "twitter:site:id",
114
+ "twitter:title"
115
+ ]);
116
+ const TAG_PRIORITY_ALIASES = ["critical", "high", "low"];
117
+ const DEPRECATED_PROPS = {
118
+ children: { replacement: "innerHTML", ruleId: "deprecated-prop-children" },
119
+ hid: { replacement: "key", ruleId: "deprecated-prop-hid-vmid" },
120
+ vmid: { replacement: "key", ruleId: "deprecated-prop-hid-vmid" },
121
+ body: { replacement: "tagPosition: 'bodyClose'", ruleId: "deprecated-prop-body" }
122
+ };
123
+
124
+ function levenshtein(a, b) {
125
+ const m = a.length;
126
+ const n = b.length;
127
+ const d = Array.from({ length: n + 1 }, (_, i) => i);
128
+ for (let i = 1; i <= m; i++) {
129
+ let prev = i - 1;
130
+ d[0] = i;
131
+ for (let j = 1; j <= n; j++) {
132
+ const tmp = d[j];
133
+ d[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, d[j], d[j - 1]);
134
+ prev = tmp;
135
+ }
136
+ }
137
+ return d[n];
138
+ }
139
+ function findClosestMatch(value, knownSet) {
140
+ const threshold = value.length <= 8 ? 2 : 3;
141
+ let best;
142
+ let bestDist = threshold + 1;
143
+ for (const known of knownSet) {
144
+ if (Math.abs(known.length - value.length) > threshold)
145
+ continue;
146
+ const dist = levenshtein(value, known);
147
+ if (dist < bestDist) {
148
+ bestDist = dist;
149
+ best = known;
150
+ }
151
+ }
152
+ return best;
153
+ }
154
+
155
+ const emptyMetaContent = (tag) => {
156
+ if (tag.tagType !== "meta")
157
+ return [];
158
+ if (!tag.keys.has("content"))
159
+ return [];
160
+ if (tag.props.content !== "")
161
+ return [];
162
+ const key = typeof tag.props.name === "string" && tag.props.name || typeof tag.props.property === "string" && tag.props.property || "meta";
163
+ const diag = {
164
+ ruleId: "empty-meta-content",
165
+ message: `Meta tag "${key}" has empty content.`,
166
+ at: { kind: "prop", key: "content" }
167
+ };
168
+ return [diag];
169
+ };
170
+
171
+ const noDeprecatedProps = (tag) => {
172
+ const out = [];
173
+ for (const key of tag.keys) {
174
+ if (!(key in DEPRECATED_PROPS))
175
+ continue;
176
+ const { replacement } = DEPRECATED_PROPS[key];
177
+ if (key === "body") {
178
+ if (tag.props.body !== true)
179
+ continue;
180
+ const fix2 = tag.keys.has("tagPosition") ? void 0 : { type: "replace-prop", key: "body", newSource: `tagPosition: 'bodyClose'` };
181
+ out.push({
182
+ ruleId: "deprecated-prop-body",
183
+ message: `"body" was removed in v3 of unhead. Use "${replacement}" instead.`,
184
+ at: { kind: "prop", key },
185
+ fix: fix2
186
+ });
187
+ continue;
188
+ }
189
+ const newKey = key === "children" ? "innerHTML" : "key";
190
+ const fix = tag.keys.has(newKey) ? void 0 : { type: "rename-prop", key, newKey };
191
+ out.push({
192
+ ruleId: key === "children" ? "deprecated-prop-children" : "deprecated-prop-hid-vmid",
193
+ message: `"${key}" was removed in v3 of unhead. Use "${replacement}" instead.`,
194
+ at: { kind: "prop", key },
195
+ fix
196
+ });
197
+ }
198
+ return out;
199
+ };
200
+
201
+ const HTML_CHARS_RE = /[<>]/;
202
+ const noHtmlInTitle = (input) => {
203
+ const title = input.props.title;
204
+ if (typeof title !== "string" || !HTML_CHARS_RE.test(title))
205
+ return [];
206
+ const diag = {
207
+ ruleId: "html-in-title",
208
+ message: `Title contains HTML characters which will be escaped, not rendered: "${title}".`,
209
+ at: { kind: "prop-value", key: "title" }
210
+ };
211
+ return [diag];
212
+ };
213
+
214
+ const OG_PREFIX_RE = /^(?:og|article|book|profile|fb):/;
215
+ const noUnknownMeta = (tag) => {
216
+ if (tag.tagType !== "meta")
217
+ return [];
218
+ const out = [];
219
+ const property = tag.props.property;
220
+ if (typeof property === "string" && !KNOWN_META_PROPERTIES.has(property) && OG_PREFIX_RE.test(property)) {
221
+ const suggestion = findClosestMatch(property, KNOWN_META_PROPERTIES);
222
+ if (suggestion) {
223
+ out.push({
224
+ ruleId: "possible-typo",
225
+ message: `Unknown meta property "${property}". Did you mean "${suggestion}"?`,
226
+ at: { kind: "prop-value", key: "property" },
227
+ fix: { type: "replace-prop-value", key: "property", newSource: `'${suggestion}'` }
228
+ });
229
+ }
230
+ }
231
+ const name = tag.props.name;
232
+ if (typeof name === "string") {
233
+ const lower = name.toLowerCase();
234
+ if (!KNOWN_META_NAMES.has(lower) && (lower.startsWith("twitter:") || lower.startsWith("fediverse:") || !lower.includes(":"))) {
235
+ const suggestion = findClosestMatch(lower, KNOWN_META_NAMES);
236
+ if (suggestion) {
237
+ out.push({
238
+ ruleId: "possible-typo",
239
+ message: `Unknown meta name "${name}". Did you mean "${suggestion}"?`,
240
+ at: { kind: "prop-value", key: "name" },
241
+ fix: { type: "replace-prop-value", key: "name", newSource: `'${suggestion}'` }
242
+ });
243
+ }
244
+ }
245
+ }
246
+ return out;
247
+ };
248
+
249
+ function isAbsolute(url) {
250
+ return url.startsWith("http://") || url.startsWith("https://");
251
+ }
252
+ const nonAbsoluteCanonical = (tag) => {
253
+ if (tag.tagType !== "link")
254
+ return [];
255
+ if (tag.props.rel !== "canonical")
256
+ return [];
257
+ const href = tag.props.href;
258
+ if (typeof href !== "string")
259
+ return [];
260
+ if (isAbsolute(href))
261
+ return [];
262
+ const diag = {
263
+ ruleId: "non-absolute-canonical",
264
+ message: `Canonical URL should be absolute, received "${href}".`,
265
+ at: { kind: "prop-value", key: "href" }
266
+ };
267
+ return [diag];
268
+ };
269
+
270
+ const ALIASES = ["critical", "high", "low"];
271
+ const numericTagPriority = (tag) => {
272
+ const value = tag.props.tagPriority;
273
+ if (typeof value !== "number")
274
+ return [];
275
+ const diag = {
276
+ ruleId: "numeric-tag-priority",
277
+ message: `Numeric tagPriority (${value}) is brittle. Prefer an alias ('critical' | 'high' | 'low') or 'before:<key>' / 'after:<key>'.`,
278
+ at: { kind: "prop-value", key: "tagPriority" },
279
+ suggestions: ALIASES.map((alias) => ({
280
+ message: `Replace with '${alias}'`,
281
+ fix: { type: "replace-prop-value", key: "tagPriority", newSource: `'${alias}'` }
282
+ }))
283
+ };
284
+ return [diag];
285
+ };
286
+
287
+ const TAG_TO_HELPER = {
288
+ link: "defineLink",
289
+ script: "defineScript"
290
+ };
291
+ const preferDefineHelpers = (tag, ctx) => {
292
+ if (!tag.inArray)
293
+ return [];
294
+ const helper = TAG_TO_HELPER[tag.tagType];
295
+ if (!helper)
296
+ return [];
297
+ const localName = ctx?.importedHelpers?.get(helper);
298
+ const imported = localName !== void 0;
299
+ const fix = { type: "wrap-tag", wrapWith: localName ?? helper };
300
+ const diag = {
301
+ ruleId: "prefer-define-helpers",
302
+ message: `Wrap this ${tag.tagType} entry in \`${helper}()\` so unhead can narrow its type.`,
303
+ fix: imported ? fix : void 0,
304
+ suggestions: imported ? void 0 : [{ message: `Wrap in \`${helper}()\` (you may need to import it).`, fix: { type: "wrap-tag", wrapWith: helper } }]
305
+ };
306
+ return [diag];
307
+ };
308
+
309
+ const preloadMissingAs = (tag) => {
310
+ if (tag.tagType !== "link")
311
+ return [];
312
+ if (tag.props.rel !== "preload")
313
+ return [];
314
+ if (tag.keys.has("as"))
315
+ return [];
316
+ const diag = {
317
+ ruleId: "preload-missing-as",
318
+ message: 'Preload link is missing the required "as" attribute.'
319
+ };
320
+ return [diag];
321
+ };
322
+ const preloadFontCrossorigin = (tag) => {
323
+ if (tag.tagType !== "link")
324
+ return [];
325
+ if (tag.props.rel !== "preload")
326
+ return [];
327
+ if (tag.props.as !== "font")
328
+ return [];
329
+ if (tag.keys.has("crossorigin"))
330
+ return [];
331
+ const diag = {
332
+ ruleId: "preload-font-crossorigin",
333
+ message: 'Font preload requires "crossorigin" \u2014 without it the font will be fetched twice.',
334
+ fix: { type: "insert-after-prop", afterKey: "as", insert: `, crossorigin: 'anonymous'` }
335
+ };
336
+ return [diag];
337
+ };
338
+
339
+ const robotsConflict = (tag) => {
340
+ if (tag.tagType !== "meta")
341
+ return [];
342
+ if (tag.props.name !== "robots")
343
+ return [];
344
+ const content = tag.props.content;
345
+ if (typeof content !== "string")
346
+ return [];
347
+ const directives = content.toLowerCase().split(",").map((d) => d.trim());
348
+ const out = [];
349
+ if (directives.includes("index") && directives.includes("noindex")) {
350
+ out.push({
351
+ ruleId: "robots-conflict",
352
+ message: 'Robots meta has conflicting "index" and "noindex" directives.'
353
+ });
354
+ }
355
+ if (directives.includes("follow") && directives.includes("nofollow")) {
356
+ out.push({
357
+ ruleId: "robots-conflict",
358
+ message: 'Robots meta has conflicting "follow" and "nofollow" directives.'
359
+ });
360
+ }
361
+ return out;
362
+ };
363
+
364
+ const deferOnModuleScript = (tag) => {
365
+ if (tag.tagType !== "script")
366
+ return [];
367
+ if (tag.props.type !== "module")
368
+ return [];
369
+ if (tag.props.defer !== true)
370
+ return [];
371
+ const diag = {
372
+ ruleId: "defer-on-module-script",
373
+ message: '"defer" is redundant on module scripts. Modules are deferred by default.',
374
+ at: { kind: "prop", key: "defer" },
375
+ fix: { type: "remove-prop", key: "defer" }
376
+ };
377
+ return [diag];
378
+ };
379
+ const scriptSrcWithContent = (tag) => {
380
+ if (tag.tagType !== "script")
381
+ return [];
382
+ if (typeof tag.props.src !== "string")
383
+ return [];
384
+ const hasInner = tag.keys.has("innerHTML") && tag.props.innerHTML !== "" || tag.keys.has("textContent") && tag.props.textContent !== "";
385
+ if (!hasInner)
386
+ return [];
387
+ const diag = {
388
+ ruleId: "script-src-with-content",
389
+ message: 'Script has both "src" and inline content. The browser will ignore the inline content.'
390
+ };
391
+ return [diag];
392
+ };
393
+
394
+ const NUMERIC_RE = /^\d+$/;
395
+ const twitterHandleMissingAt = (tag) => {
396
+ if (tag.tagType !== "meta")
397
+ return [];
398
+ const name = tag.props.name;
399
+ if (name !== "twitter:site" && name !== "twitter:creator")
400
+ return [];
401
+ const content = tag.props.content;
402
+ if (typeof content !== "string")
403
+ return [];
404
+ if (content.startsWith("@") || NUMERIC_RE.test(content))
405
+ return [];
406
+ const fixedSource = JSON.stringify(`@${content}`);
407
+ const diag = {
408
+ ruleId: "twitter-handle-missing-at",
409
+ message: `${name} should start with "@", received "${content}".`,
410
+ at: { kind: "prop-value", key: "content" },
411
+ fix: { type: "replace-prop-value", key: "content", newSource: fixedSource }
412
+ };
413
+ return [diag];
414
+ };
415
+
416
+ const USER_SCALABLE_NO_RE = /user-scalable\s*=\s*(?:no|0|false)(?:[\s,;]|$)/i;
417
+ const MAX_SCALE_RE = /maximum-scale\s*=\s*1(?:\.\d+)?(?:[\s,;]|$)/i;
418
+ const viewportUserScalable = (tag) => {
419
+ if (tag.tagType !== "meta")
420
+ return [];
421
+ if (tag.props.name !== "viewport")
422
+ return [];
423
+ const content = tag.props.content;
424
+ if (typeof content !== "string")
425
+ return [];
426
+ const out = [];
427
+ if (USER_SCALABLE_NO_RE.test(content)) {
428
+ out.push({
429
+ ruleId: "viewport-user-scalable",
430
+ message: 'viewport "user-scalable=no" prevents zooming and harms accessibility.'
431
+ });
432
+ }
433
+ if (MAX_SCALE_RE.test(content)) {
434
+ out.push({
435
+ ruleId: "viewport-user-scalable",
436
+ message: 'viewport "maximum-scale=1" limits zooming and may harm accessibility.'
437
+ });
438
+ }
439
+ return out;
440
+ };
441
+
442
+ const TAG_TYPES = /* @__PURE__ */ new Set(["meta", "link", "script", "noscript", "style"]);
443
+ function tagInputFromRuntime(tag) {
444
+ if (!TAG_TYPES.has(tag.tag))
445
+ return void 0;
446
+ const props = {};
447
+ const keys = /* @__PURE__ */ new Set();
448
+ for (const [k, v] of Object.entries(tag.props)) {
449
+ keys.add(k);
450
+ if (v == null) {
451
+ if (tag.tag === "meta" && k === "content")
452
+ props[k] = "";
453
+ continue;
454
+ }
455
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
456
+ if (tag.tag === "meta" && k === "name" && typeof v === "string")
457
+ props[k] = v.toLowerCase();
458
+ else
459
+ props[k] = v;
460
+ } else {
461
+ props[k] = String(v);
462
+ }
463
+ }
464
+ if (tag.tag === "script" || tag.tag === "style" || tag.tag === "noscript") {
465
+ if (tag.innerHTML != null && tag.innerHTML !== "")
466
+ keys.add("innerHTML");
467
+ if (tag.textContent != null && tag.textContent !== "")
468
+ keys.add("textContent");
469
+ }
470
+ if (tag.tagPriority != null) {
471
+ keys.add("tagPriority");
472
+ if (typeof tag.tagPriority === "string" || typeof tag.tagPriority === "number")
473
+ props.tagPriority = tag.tagPriority;
474
+ }
475
+ return {
476
+ tagType: tag.tag,
477
+ props,
478
+ keys
479
+ };
480
+ }
481
+ function titleInputFromRuntime(titleTag) {
482
+ if (titleTag.tag !== "title")
483
+ return void 0;
484
+ const text = titleTag.textContent ?? titleTag.innerHTML ?? "";
485
+ return {
486
+ callee: "runtime",
487
+ props: { title: text },
488
+ keys: /* @__PURE__ */ new Set(["title"])
489
+ };
490
+ }
491
+
492
+ const tagPredicates = {
493
+ "defer-on-module-script": deferOnModuleScript,
494
+ "empty-meta-content": emptyMetaContent,
495
+ "no-deprecated-props": noDeprecatedProps,
496
+ "no-unknown-meta": noUnknownMeta,
497
+ "non-absolute-canonical": nonAbsoluteCanonical,
498
+ "numeric-tag-priority": numericTagPriority,
499
+ "preload-font-crossorigin": preloadFontCrossorigin,
500
+ "preload-missing-as": preloadMissingAs,
501
+ "robots-conflict": robotsConflict,
502
+ "script-src-with-content": scriptSrcWithContent,
503
+ "twitter-handle-missing-at": twitterHandleMissingAt,
504
+ "viewport-user-scalable": viewportUserScalable
505
+ };
506
+ const migrationTagPredicates = {
507
+ "prefer-define-helpers": preferDefineHelpers
508
+ };
509
+ const headInputPredicates = {
510
+ "no-html-in-title": noHtmlInTitle
511
+ };
512
+
513
+ export { DEPRECATED_PROPS as D, KNOWN_META_NAMES as K, TAG_PRIORITY_ALIASES as T, URL_META_KEYS as U, KNOWN_META_PROPERTIES as a, noHtmlInTitle as b, noUnknownMeta as c, deferOnModuleScript as d, emptyMetaContent as e, findClosestMatch as f, nonAbsoluteCanonical as g, headInputPredicates as h, numericTagPriority as i, preloadFontCrossorigin as j, preloadMissingAs as k, levenshtein as l, migrationTagPredicates as m, noDeprecatedProps as n, tagPredicates as o, preferDefineHelpers as p, titleInputFromRuntime as q, robotsConflict as r, scriptSrcWithContent as s, tagInputFromRuntime as t, twitterHandleMissingAt as u, viewportUserScalable as v };
@@ -1,13 +1,32 @@
1
1
  import { C as ClientUnhead } from '../shared/unhead.Cv5yrrUd.mjs';
2
- import { c as CreateClientHeadOptions, U as Unhead } from '../shared/unhead.BoZ-Ul8T.mjs';
3
2
  import { aP as SerializableHead, aw as ResolvableHead } from '../shared/unhead.DeoGMp34.mjs';
3
+ import { U as Unhead, c as CreateClientHeadOptions } from '../shared/unhead.BoZ-Ul8T.mjs';
4
4
  import 'hookable';
5
5
 
6
- interface UnheadStreamQueue {
6
+ /**
7
+ * Shape of the streaming queue written to `window[streamKey]` (default
8
+ * `window.__unhead__`) by the server-emitted bootstrap script. The client
9
+ * IIFE reads from it to replay queued entries, and the streaming client
10
+ * wraps the resulting head instance.
11
+ *
12
+ * Both the server bootstrap (`createBootstrapScript`) and the client
13
+ * (`createStreamableHead`, iife) must agree on this shape.
14
+ */
15
+ interface StreamingGlobal {
16
+ /** Queued entry batches pushed before the client IIFE took over. */
7
17
  _q: SerializableHead[][];
18
+ /** Resolved Unhead instance, populated once the IIFE initialises. */
8
19
  _head?: Unhead<any>;
20
+ /** True while framework hydration is in progress (client push suppression). */
21
+ _hydrationLocked?: () => boolean;
22
+ /** Push an entry batch onto the queue (pre-init) or the head (post-init). */
9
23
  push: (entries: SerializableHead[]) => void;
10
24
  }
25
+ /**
26
+ * @deprecated Use `StreamingGlobal` instead. Kept as an alias for back-compat.
27
+ */
28
+ type UnheadStreamQueue = StreamingGlobal;
29
+
11
30
  declare const DEFAULT_STREAM_KEY = "__unhead__";
12
31
  interface CreateStreamableClientHeadOptions extends Omit<CreateClientHeadOptions, 'render'> {
13
32
  streamKey?: string;
@@ -19,4 +38,4 @@ interface CreateStreamableClientHeadOptions extends Omit<CreateClientHeadOptions
19
38
  declare function createStreamableHead<T = ResolvableHead>(options?: CreateStreamableClientHeadOptions): ClientUnhead<T> | undefined;
20
39
 
21
40
  export { DEFAULT_STREAM_KEY, createStreamableHead };
22
- export type { CreateStreamableClientHeadOptions, UnheadStreamQueue };
41
+ export type { CreateStreamableClientHeadOptions, StreamingGlobal, UnheadStreamQueue };
@@ -1,13 +1,32 @@
1
1
  import { C as ClientUnhead } from '../shared/unhead.-D8hRpkn.js';
2
- import { c as CreateClientHeadOptions, U as Unhead } from '../shared/unhead.gui9LmZS.js';
3
2
  import { aP as SerializableHead, aw as ResolvableHead } from '../shared/unhead.DeoGMp34.js';
3
+ import { U as Unhead, c as CreateClientHeadOptions } from '../shared/unhead.gui9LmZS.js';
4
4
  import 'hookable';
5
5
 
6
- interface UnheadStreamQueue {
6
+ /**
7
+ * Shape of the streaming queue written to `window[streamKey]` (default
8
+ * `window.__unhead__`) by the server-emitted bootstrap script. The client
9
+ * IIFE reads from it to replay queued entries, and the streaming client
10
+ * wraps the resulting head instance.
11
+ *
12
+ * Both the server bootstrap (`createBootstrapScript`) and the client
13
+ * (`createStreamableHead`, iife) must agree on this shape.
14
+ */
15
+ interface StreamingGlobal {
16
+ /** Queued entry batches pushed before the client IIFE took over. */
7
17
  _q: SerializableHead[][];
18
+ /** Resolved Unhead instance, populated once the IIFE initialises. */
8
19
  _head?: Unhead<any>;
20
+ /** True while framework hydration is in progress (client push suppression). */
21
+ _hydrationLocked?: () => boolean;
22
+ /** Push an entry batch onto the queue (pre-init) or the head (post-init). */
9
23
  push: (entries: SerializableHead[]) => void;
10
24
  }
25
+ /**
26
+ * @deprecated Use `StreamingGlobal` instead. Kept as an alias for back-compat.
27
+ */
28
+ type UnheadStreamQueue = StreamingGlobal;
29
+
11
30
  declare const DEFAULT_STREAM_KEY = "__unhead__";
12
31
  interface CreateStreamableClientHeadOptions extends Omit<CreateClientHeadOptions, 'render'> {
13
32
  streamKey?: string;
@@ -19,4 +38,4 @@ interface CreateStreamableClientHeadOptions extends Omit<CreateClientHeadOptions
19
38
  declare function createStreamableHead<T = ResolvableHead>(options?: CreateStreamableClientHeadOptions): ClientUnhead<T> | undefined;
20
39
 
21
40
  export { DEFAULT_STREAM_KEY, createStreamableHead };
22
- export type { CreateStreamableClientHeadOptions, UnheadStreamQueue };
41
+ export type { CreateStreamableClientHeadOptions, StreamingGlobal, UnheadStreamQueue };
@@ -67,9 +67,10 @@ declare function createStreamableHead<T = ResolvableHead>(options?: CreateStream
67
67
  * use this directly to inject the bootstrap into your shell `<head>`.
68
68
  *
69
69
  * @param streamKey - The window property name for the stream queue (default: '__unhead__')
70
+ * @param nonce - Optional CSP nonce to stamp on the script tag
70
71
  * @returns An inline `<script>` tag string
71
72
  */
72
- declare function createBootstrapScript(streamKey?: string): string;
73
+ declare function createBootstrapScript(streamKey?: string, nonce?: string): string;
73
74
  /**
74
75
  * Renders the current head state and clears entries atomically.
75
76
  *
@@ -166,8 +167,10 @@ interface StreamingTemplateParts {
166
167
  /**
167
168
  * @experimental
168
169
  *
169
- * Prepares a template for streaming SSR by splitting it at body tag boundaries
170
- * and injecting head content into both parts.
170
+ * Prepares a template for streaming SSR by splitting it at the SSR outlet
171
+ * marker (`<!--app-html-->` / `<!--ssr-outlet-->`) when present, so the
172
+ * streamed app content lands inside the mount container. Falls back to
173
+ * splitting at body tag boundaries when no marker is found.
171
174
  *
172
175
  * This is the recommended way to handle streaming templates as it:
173
176
  * - Uses consistent template parsing (same as transformHtmlTemplateRaw)
@@ -67,9 +67,10 @@ declare function createStreamableHead<T = ResolvableHead>(options?: CreateStream
67
67
  * use this directly to inject the bootstrap into your shell `<head>`.
68
68
  *
69
69
  * @param streamKey - The window property name for the stream queue (default: '__unhead__')
70
+ * @param nonce - Optional CSP nonce to stamp on the script tag
70
71
  * @returns An inline `<script>` tag string
71
72
  */
72
- declare function createBootstrapScript(streamKey?: string): string;
73
+ declare function createBootstrapScript(streamKey?: string, nonce?: string): string;
73
74
  /**
74
75
  * Renders the current head state and clears entries atomically.
75
76
  *
@@ -166,8 +167,10 @@ interface StreamingTemplateParts {
166
167
  /**
167
168
  * @experimental
168
169
  *
169
- * Prepares a template for streaming SSR by splitting it at body tag boundaries
170
- * and injecting head content into both parts.
170
+ * Prepares a template for streaming SSR by splitting it at the SSR outlet
171
+ * marker (`<!--app-html-->` / `<!--ssr-outlet-->`) when present, so the
172
+ * streamed app content lands inside the mount container. Falls back to
173
+ * splitting at body tag boundaries when no marker is found.
171
174
  *
172
175
  * This is the recommended way to handle streaming templates as it:
173
176
  * - Uses consistent template parsing (same as transformHtmlTemplateRaw)
@@ -42,9 +42,10 @@ function getStreamKey(head) {
42
42
  assertValidStreamKey(key);
43
43
  return key;
44
44
  }
45
- function createBootstrapScript(streamKey = DEFAULT_STREAM_KEY) {
45
+ function createBootstrapScript(streamKey = DEFAULT_STREAM_KEY, nonce) {
46
46
  assertValidStreamKey(streamKey);
47
- return `<script>window.${streamKey}={_q:[],push(e){this._q.push(e)}}<\/script>`;
47
+ const nonceAttr = nonce ? ` nonce="${nonce.replace(/"/g, "&quot;")}"` : "";
48
+ return `<script${nonceAttr}>window.${streamKey}={_q:[],push(e){this._q.push(e)}}<\/script>`;
48
49
  }
49
50
  function renderShell(head) {
50
51
  const result = head.render();
@@ -110,8 +111,18 @@ function prepareStreamingTemplate(head, template, preRenderedState) {
110
111
  const bodyEnd = parsed.indexes.bodyTagEnd;
111
112
  const bodyCloseStart = parsed.indexes.bodyCloseTagStart;
112
113
  if (bodyEnd >= 0 && bodyCloseStart >= 0) {
113
- const shellPart = template.substring(0, bodyEnd);
114
114
  const bodyInterior = template.substring(bodyEnd, bodyCloseStart);
115
+ const markerMatch = bodyInterior.match(/<!--\s*(?:app-html|ssr-outlet)\s*-->/);
116
+ let beforeStream;
117
+ let afterStream;
118
+ if (markerMatch) {
119
+ beforeStream = bodyInterior.substring(0, markerMatch.index);
120
+ afterStream = bodyInterior.substring(markerMatch.index + markerMatch[0].length);
121
+ } else {
122
+ beforeStream = "";
123
+ afterStream = bodyInterior;
124
+ }
125
+ const shellPart = template.substring(0, bodyEnd) + beforeStream;
115
126
  const endPart = template.substring(bodyCloseStart);
116
127
  const shellParsed = parseHtmlForIndexes(`${shellPart}</body></html>`);
117
128
  const shell = applyHeadToHtml(shellParsed, {
@@ -122,7 +133,7 @@ function prepareStreamingTemplate(head, template, preRenderedState) {
122
133
  }).replace("</body></html>", "");
123
134
  return {
124
135
  shell,
125
- end: bodyInterior + ssr.bodyTags + endPart
136
+ end: afterStream + ssr.bodyTags + endPart
126
137
  };
127
138
  }
128
139
  return {