@chr33s/solarflare 0.0.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.
Files changed (47) hide show
  1. package/package.json +52 -0
  2. package/readme.md +183 -0
  3. package/src/ast.ts +316 -0
  4. package/src/build.bundle-client.ts +404 -0
  5. package/src/build.bundle-server.ts +131 -0
  6. package/src/build.bundle.ts +48 -0
  7. package/src/build.emit-manifests.ts +25 -0
  8. package/src/build.hmr-entry.ts +88 -0
  9. package/src/build.scan.ts +182 -0
  10. package/src/build.ts +227 -0
  11. package/src/build.validate.ts +63 -0
  12. package/src/client.hmr.ts +78 -0
  13. package/src/client.styles.ts +68 -0
  14. package/src/client.ts +190 -0
  15. package/src/codemod.ts +688 -0
  16. package/src/console-forward.ts +254 -0
  17. package/src/critical-css.ts +103 -0
  18. package/src/devtools-json.ts +52 -0
  19. package/src/diff-dom-streaming.ts +406 -0
  20. package/src/early-flush.ts +125 -0
  21. package/src/early-hints.ts +83 -0
  22. package/src/fetch.ts +44 -0
  23. package/src/fs.ts +11 -0
  24. package/src/head.ts +876 -0
  25. package/src/hmr.ts +647 -0
  26. package/src/hydration.ts +238 -0
  27. package/src/manifest.runtime.ts +25 -0
  28. package/src/manifest.ts +23 -0
  29. package/src/paths.ts +96 -0
  30. package/src/render-priority.ts +69 -0
  31. package/src/route-cache.ts +163 -0
  32. package/src/router-deferred.ts +85 -0
  33. package/src/router-stream.ts +65 -0
  34. package/src/router.ts +535 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/serialize.ts +38 -0
  37. package/src/server.hmr.ts +67 -0
  38. package/src/server.styles.ts +42 -0
  39. package/src/server.ts +480 -0
  40. package/src/solarflare.d.ts +101 -0
  41. package/src/speculation-rules.ts +171 -0
  42. package/src/store.ts +78 -0
  43. package/src/stream-assets.ts +135 -0
  44. package/src/stylesheets.ts +222 -0
  45. package/src/worker.config.ts +243 -0
  46. package/src/worker.ts +542 -0
  47. package/tsconfig.json +21 -0
package/src/head.ts ADDED
@@ -0,0 +1,876 @@
1
+ import { type VNode, h, type ComponentChildren, options } from "preact";
2
+ import { signal, type Signal } from "@preact/signals";
3
+
4
+ /** Supported head tag names. */
5
+ export type HeadTagName = "title" | "meta" | "link" | "script" | "base" | "style" | "noscript";
6
+
7
+ /** Tag priority for ordering. */
8
+ export type TagPriority = "critical" | "high" | number | "low";
9
+
10
+ /** Tag position in the document. */
11
+ export type TagPosition = "head" | "bodyOpen" | "bodyClose";
12
+
13
+ /** Base head tag structure. */
14
+ export interface HeadTag {
15
+ tag: HeadTagName;
16
+ props: Record<string, string | boolean | null | undefined>;
17
+ /** Inner content (for title, script, style). */
18
+ textContent?: string;
19
+ key?: string;
20
+ /** Priority for ordering (lower = earlier). */
21
+ tagPriority?: TagPriority;
22
+ tagPosition?: TagPosition;
23
+ /** Internal: calculated weight for sorting. */
24
+ _w?: number;
25
+ /** Internal: entry position. */
26
+ _p?: number;
27
+ /** Internal: dedupe key. */
28
+ _d?: string;
29
+ }
30
+
31
+ /** Head input schema (similar to unhead). */
32
+ export interface HeadInput {
33
+ title?: string;
34
+ /** Title template (function or string with %s). */
35
+ titleTemplate?: string | ((title?: string) => string);
36
+ base?: { href?: string; target?: string };
37
+ meta?: Array<{
38
+ charset?: string;
39
+ name?: string;
40
+ property?: string;
41
+ "http-equiv"?: string;
42
+ content?: string;
43
+ key?: string;
44
+ }>;
45
+ link?: Array<{
46
+ rel?: string;
47
+ href?: string;
48
+ type?: string;
49
+ sizes?: string;
50
+ media?: string;
51
+ crossorigin?: string;
52
+ as?: string;
53
+ key?: string;
54
+ }>;
55
+ script?: Array<{
56
+ src?: string;
57
+ type?: string;
58
+ async?: boolean;
59
+ defer?: boolean;
60
+ innerHTML?: string;
61
+ key?: string;
62
+ }>;
63
+ style?: Array<{
64
+ type?: string;
65
+ media?: string;
66
+ innerHTML?: string;
67
+ key?: string;
68
+ }>;
69
+ htmlAttrs?: Record<string, string>;
70
+ bodyAttrs?: Record<string, string>;
71
+ }
72
+
73
+ /** Active head entry with lifecycle methods. */
74
+ export interface ActiveHeadEntry {
75
+ patch: (input: Partial<HeadInput>) => void;
76
+ dispose: () => void;
77
+ }
78
+
79
+ /** Head entry options. */
80
+ export interface HeadEntryOptions {
81
+ tagPriority?: TagPriority;
82
+ tagPosition?: TagPosition;
83
+ }
84
+
85
+ /** Tags that can only appear once. */
86
+ const UNIQUE_TAGS = new Set(["base", "title", "titleTemplate", "htmlAttrs", "bodyAttrs"]);
87
+
88
+ /** Head tag names that should be hoisted. */
89
+ const HEAD_TAG_NAMES = new Set<string>([
90
+ "title",
91
+ "meta",
92
+ "link",
93
+ "script",
94
+ "base",
95
+ "style",
96
+ "noscript",
97
+ ]);
98
+
99
+ /** Tags with inner content. */
100
+ const TAGS_WITH_CONTENT = new Set(["title", "script", "style", "noscript"]);
101
+
102
+ /** Self-closing tags. */
103
+ const SELF_CLOSING_TAGS = new Set(["meta", "link", "base"]);
104
+
105
+ /** Standard meta tags that should always deduplicate (not allow multiples). */
106
+ const SINGLE_VALUE_META = new Set(["viewport", "description", "keywords", "robots", "charset"]);
107
+
108
+ /** Tag weight map for sorting (lower = earlier in head). */
109
+ const TAG_WEIGHTS: Record<string, number> = {
110
+ base: 1,
111
+ title: 10,
112
+ meta: 20, // charset/viewport get special handling
113
+ link: 30,
114
+ style: 40,
115
+ script: 50,
116
+ noscript: 60,
117
+ };
118
+
119
+ /** Priority aliases. */
120
+ const PRIORITY_ALIASES: Record<string, number> = {
121
+ critical: -80,
122
+ high: -10,
123
+ low: 50,
124
+ };
125
+
126
+ /** Extracts text content from VNode children. */
127
+ function getTextContent(children: ComponentChildren): string {
128
+ if (typeof children === "string") return children;
129
+ if (typeof children === "number") return String(children);
130
+ if (Array.isArray(children)) return children.map(getTextContent).join("");
131
+ return "";
132
+ }
133
+
134
+ /** Whether head hoisting has been installed. */
135
+ let hoistingInstalled = false;
136
+
137
+ /** Track if head tags were collected during this render (for client-side DOM updates). */
138
+ let headTagsCollectedThisRender = false;
139
+
140
+ /** Installs the VNode hook to automatically hoist head tags. */
141
+ export function installHeadHoisting() {
142
+ if (hoistingInstalled) return;
143
+ hoistingInstalled = true;
144
+
145
+ // Store the previous vnode hook (if any)
146
+ // eslint-disable-next-line @typescript-eslint/unbound-method
147
+ const prevVnode = options.vnode;
148
+ // eslint-disable-next-line @typescript-eslint/unbound-method
149
+ const prevDiffed = options.diffed;
150
+
151
+ options.vnode = (vnode: VNode) => {
152
+ // Call previous hook first
153
+ if (prevVnode) prevVnode(vnode);
154
+
155
+ const type = vnode.type;
156
+
157
+ // Skip processing of structural elements
158
+ if (type === "head" || type === "body" || type === "html") {
159
+ return;
160
+ }
161
+
162
+ // Check if this is a head tag that should be collected for deduplication
163
+ // ALL head tags (both in layout's <head> and in components) go through
164
+ // the head context for proper deduplication, then render at <Head /> marker
165
+ if (typeof type === "string" && HEAD_TAG_NAMES.has(type)) {
166
+ // Extract the head input from this vnode
167
+ const input = vnodeToHeadInput(vnode);
168
+ if (input) {
169
+ // Register with head context for deduplication
170
+ const ctx = headContext;
171
+ if (ctx) {
172
+ ctx.push(input);
173
+ headTagsCollectedThisRender = true;
174
+ }
175
+ // Replace the vnode with null to prevent it from rendering in place
176
+ // All head tags will be rendered (deduplicated) at the <Head /> marker
177
+ vnode.type = NullComponent;
178
+ (vnode as VNode<{ children?: ComponentChildren }>).props = {
179
+ children: null,
180
+ };
181
+ }
182
+ }
183
+ };
184
+
185
+ // On client side, apply head tags to DOM after render completes
186
+ options.diffed = (vnode: VNode) => {
187
+ if (prevDiffed) prevDiffed(vnode);
188
+
189
+ // Only apply on client side, when head tags were collected
190
+ if (typeof document !== "undefined" && headTagsCollectedThisRender) {
191
+ headTagsCollectedThisRender = false;
192
+ const ctx = headContext;
193
+ if (ctx) {
194
+ applyHeadToDOM(ctx.resolveTags());
195
+ }
196
+ }
197
+ };
198
+ }
199
+
200
+ /** Component that renders nothing. */
201
+ function NullComponent() {
202
+ return null;
203
+ }
204
+
205
+ /** Converts a VNode to HeadInput. */
206
+ function vnodeToHeadInput(vnode: VNode) {
207
+ const type = vnode.type;
208
+ const props = (vnode.props || {}) as Record<string, unknown>;
209
+
210
+ if (typeof type !== "string") return null;
211
+
212
+ switch (type) {
213
+ case "title":
214
+ return { title: getTextContent(props.children as ComponentChildren) };
215
+ case "meta": {
216
+ const { children: _, ...metaProps } = props;
217
+ return { meta: [metaProps as NonNullable<HeadInput["meta"]>[number]] };
218
+ }
219
+ case "link": {
220
+ const { children: _, ...linkProps } = props;
221
+ return { link: [linkProps as NonNullable<HeadInput["link"]>[number]] };
222
+ }
223
+ case "script": {
224
+ const { children, ...scriptProps } = props;
225
+ return {
226
+ script: [
227
+ {
228
+ ...scriptProps,
229
+ innerHTML: getTextContent(children as ComponentChildren),
230
+ } as NonNullable<HeadInput["script"]>[number],
231
+ ],
232
+ };
233
+ }
234
+ case "style": {
235
+ const { children, ...styleProps } = props;
236
+ return {
237
+ style: [
238
+ {
239
+ ...styleProps,
240
+ innerHTML: getTextContent(children as ComponentChildren),
241
+ } as NonNullable<HeadInput["style"]>[number],
242
+ ],
243
+ };
244
+ }
245
+ case "base": {
246
+ const { children: _, ...baseProps } = props;
247
+ return { base: baseProps as HeadInput["base"] };
248
+ }
249
+ case "noscript":
250
+ // noscript is less common, just skip for now
251
+ return null;
252
+ default:
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /** Resets head element tracking (call between SSR requests). */
258
+ export function resetHeadElementTracking() {
259
+ // No-op: hoisting is now stateless (all head tags go through context)
260
+ // Kept for API compatibility
261
+ }
262
+
263
+ /** Global head context for SSR. */
264
+ let headContext: HeadContext | null = null;
265
+
266
+ /** Head context for collecting tags during render. */
267
+ export interface HeadContext {
268
+ /** Collected head entries. */
269
+ entries: HeadEntry[];
270
+ /** Title template. */
271
+ titleTemplate?: string | ((title?: string) => string);
272
+ /** HTML attributes. */
273
+ htmlAttrs: Record<string, string>;
274
+ /** Body attributes. */
275
+ bodyAttrs: Record<string, string>;
276
+ /** Add a head entry. */
277
+ push: (input: HeadInput, options?: HeadEntryOptions) => ActiveHeadEntry;
278
+ /** Resolve all tags with deduplication and sorting. */
279
+ resolveTags: () => HeadTag[];
280
+ /** Render tags to HTML string. */
281
+ renderToString: () => string;
282
+ /** Reset context. */
283
+ reset: () => void;
284
+ }
285
+
286
+ /** Internal head entry. */
287
+ interface HeadEntry {
288
+ id: number;
289
+ input: HeadInput;
290
+ options?: HeadEntryOptions;
291
+ _tags?: HeadTag[];
292
+ }
293
+
294
+ let entryId = 0;
295
+
296
+ /** Creates a new head context. */
297
+ export function createHeadContext() {
298
+ const entries: HeadEntry[] = [];
299
+ const htmlAttrs: Record<string, string> = {};
300
+ const bodyAttrs: Record<string, string> = {};
301
+
302
+ const context: HeadContext = {
303
+ entries,
304
+ titleTemplate: undefined,
305
+ htmlAttrs,
306
+ bodyAttrs,
307
+
308
+ push(input: HeadInput, options?: HeadEntryOptions) {
309
+ const id = ++entryId;
310
+ const entry: HeadEntry = { id, input, options };
311
+ entries.push(entry);
312
+
313
+ // Handle title template
314
+ if (input.titleTemplate) {
315
+ context.titleTemplate = input.titleTemplate;
316
+ }
317
+
318
+ // Handle HTML/body attrs
319
+ if (input.htmlAttrs) {
320
+ Object.assign(htmlAttrs, input.htmlAttrs);
321
+ }
322
+ if (input.bodyAttrs) {
323
+ Object.assign(bodyAttrs, input.bodyAttrs);
324
+ }
325
+
326
+ return {
327
+ patch: (newInput: Partial<HeadInput>) => {
328
+ entry.input = { ...entry.input, ...newInput };
329
+ entry._tags = undefined; // Clear cached tags
330
+ },
331
+ dispose: () => {
332
+ const idx = entries.findIndex((e) => e.id === id);
333
+ if (idx !== -1) entries.splice(idx, 1);
334
+ },
335
+ };
336
+ },
337
+
338
+ resolveTags() {
339
+ // Normalize all entries to tags
340
+ for (const entry of entries) {
341
+ if (!entry._tags) {
342
+ entry._tags = normalizeInputToTags(entry.input, entry.options);
343
+ }
344
+ }
345
+
346
+ // Flatten all tags
347
+ const allTags = entries.flatMap((e) => e._tags || []);
348
+
349
+ // Apply title template
350
+ if (context.titleTemplate) {
351
+ const titleTag = allTags.find((t) => t.tag === "title");
352
+ if (titleTag?.textContent) {
353
+ const template = context.titleTemplate;
354
+ titleTag.textContent =
355
+ typeof template === "function"
356
+ ? template(titleTag.textContent)
357
+ : template.replace("%s", titleTag.textContent);
358
+ }
359
+ }
360
+
361
+ // Assign weights and positions
362
+ allTags.forEach((tag, i) => {
363
+ tag._w = tagWeight(tag);
364
+ tag._p = i;
365
+ tag._d = dedupeKey(tag);
366
+ });
367
+
368
+ // Deduplicate: last wins for same dedupe key
369
+ const tagMap = new Map<string, HeadTag>();
370
+ for (const tag of allTags) {
371
+ const key = tag._d || String(tag._p);
372
+ tagMap.set(key, tag);
373
+ }
374
+
375
+ // Sort by weight
376
+ return Array.from(tagMap.values()).sort((a, b) => (a._w ?? 100) - (b._w ?? 100));
377
+ },
378
+
379
+ renderToString() {
380
+ const tags = context.resolveTags();
381
+ return tags.map(tagToHtml).join("\n");
382
+ },
383
+
384
+ reset() {
385
+ entries.length = 0;
386
+ context.titleTemplate = undefined;
387
+ Object.keys(htmlAttrs).forEach((k) => delete htmlAttrs[k]);
388
+ Object.keys(bodyAttrs).forEach((k) => delete bodyAttrs[k]);
389
+ },
390
+ };
391
+
392
+ return context;
393
+ }
394
+
395
+ /** Resets the entry ID counter (call between SSR requests to prevent overflow). */
396
+ function resetEntryIdCounter() {
397
+ entryId = 0;
398
+ }
399
+
400
+ /** Gets or creates the global head context. */
401
+ export function getHeadContext() {
402
+ if (!headContext) {
403
+ headContext = createHeadContext();
404
+ }
405
+ return headContext;
406
+ }
407
+
408
+ /** Sets the global head context (for SSR). */
409
+ export function setHeadContext(ctx: HeadContext | null) {
410
+ headContext = ctx;
411
+ }
412
+
413
+ /** Resets the global head context. */
414
+ export function resetHeadContext() {
415
+ if (headContext) {
416
+ headContext.reset();
417
+ }
418
+ // Reset entry ID counter to prevent overflow in long-running scenarios
419
+ resetEntryIdCounter();
420
+ }
421
+
422
+ /** Generates dedupe key for a tag. */
423
+ export function dedupeKey(tag: HeadTag) {
424
+ const { props, tag: name } = tag;
425
+
426
+ // Unique singleton tags
427
+ if (UNIQUE_TAGS.has(name)) {
428
+ return name;
429
+ }
430
+
431
+ // Manual key
432
+ if (tag.key) {
433
+ return `${name}:key:${tag.key}`;
434
+ }
435
+
436
+ // Canonical link
437
+ if (name === "link" && props.rel === "canonical") {
438
+ return "canonical";
439
+ }
440
+
441
+ // Charset meta
442
+ if (props.charset) {
443
+ return "charset";
444
+ }
445
+
446
+ // Meta tags dedupe by name/property/http-equiv
447
+ if (name === "meta") {
448
+ for (const attr of ["name", "property", "http-equiv"]) {
449
+ const value = props[attr];
450
+ if (value !== undefined) {
451
+ // Structured properties (og:image:width) or standard single-value metas dedupe
452
+ const isStructured = typeof value === "string" && value.includes(":");
453
+ const isSingleValue = SINGLE_VALUE_META.has(String(value));
454
+ if (isStructured || isSingleValue || !tag.key) {
455
+ return `meta:${value}`;
456
+ }
457
+ return `meta:${value}:key:${tag.key}`;
458
+ }
459
+ }
460
+ }
461
+
462
+ // Link tags with id
463
+ if (props.id) {
464
+ return `${name}:id:${props.id}`;
465
+ }
466
+
467
+ // Content-based dedupe for script/style
468
+ if (TAGS_WITH_CONTENT.has(name) && tag.textContent) {
469
+ return `${name}:content:${hashString(tag.textContent)}`;
470
+ }
471
+
472
+ return undefined;
473
+ }
474
+
475
+ /** Simple string hash for content-based deduplication. */
476
+ function hashString(str: string) {
477
+ let hash = 0;
478
+ for (let i = 0; i < str.length; i++) {
479
+ const char = str.charCodeAt(i);
480
+ hash = (hash << 5) - hash + char;
481
+ hash |= 0;
482
+ }
483
+ return hash.toString(36);
484
+ }
485
+
486
+ /** Calculates tag weight for sorting (lower = earlier in head). */
487
+ export function tagWeight(tag: HeadTag) {
488
+ // Priority overrides
489
+ if (typeof tag.tagPriority === "number") {
490
+ return tag.tagPriority;
491
+ }
492
+ if (tag.tagPriority && tag.tagPriority in PRIORITY_ALIASES) {
493
+ return PRIORITY_ALIASES[tag.tagPriority];
494
+ }
495
+
496
+ // Base weight by tag type
497
+ let weight = TAG_WEIGHTS[tag.tag] ?? 100;
498
+
499
+ // Special handling for critical meta tags
500
+ if (tag.tag === "meta") {
501
+ if (tag.props.charset) return 1; // charset first
502
+ if (tag.props.name === "viewport") return 2;
503
+ if (tag.props["http-equiv"] === "content-security-policy") return 3;
504
+ }
505
+
506
+ // Preload/preconnect links should be early
507
+ if (tag.tag === "link") {
508
+ const rel = tag.props.rel;
509
+ if (rel === "preconnect") return 5;
510
+ if (rel === "dns-prefetch") return 6;
511
+ if (rel === "preload") return 7;
512
+ if (rel === "prefetch") return 35;
513
+ }
514
+
515
+ return weight;
516
+ }
517
+
518
+ /** Normalizes HeadInput to HeadTag array. */
519
+ export function normalizeInputToTags(input: HeadInput, options?: HeadEntryOptions) {
520
+ const tags: HeadTag[] = [];
521
+
522
+ // Title
523
+ if (input.title) {
524
+ tags.push({
525
+ tag: "title",
526
+ props: {},
527
+ textContent: input.title,
528
+ tagPriority: options?.tagPriority,
529
+ });
530
+ }
531
+
532
+ // Base
533
+ if (input.base) {
534
+ tags.push({
535
+ tag: "base",
536
+ props: input.base,
537
+ tagPriority: options?.tagPriority,
538
+ });
539
+ }
540
+
541
+ // Meta
542
+ if (input.meta) {
543
+ for (const meta of input.meta) {
544
+ const { key, ...props } = meta;
545
+ tags.push({
546
+ tag: "meta",
547
+ props,
548
+ key,
549
+ tagPriority: options?.tagPriority,
550
+ });
551
+ }
552
+ }
553
+
554
+ // Link
555
+ if (input.link) {
556
+ for (const link of input.link) {
557
+ const { key, ...props } = link;
558
+ tags.push({
559
+ tag: "link",
560
+ props,
561
+ key,
562
+ tagPriority: options?.tagPriority,
563
+ });
564
+ }
565
+ }
566
+
567
+ // Script
568
+ if (input.script) {
569
+ for (const script of input.script) {
570
+ const { key, innerHTML, ...props } = script;
571
+ tags.push({
572
+ tag: "script",
573
+ props,
574
+ textContent: innerHTML,
575
+ key,
576
+ tagPriority: options?.tagPriority,
577
+ });
578
+ }
579
+ }
580
+
581
+ // Style
582
+ if (input.style) {
583
+ for (const style of input.style) {
584
+ const { key, innerHTML, ...props } = style;
585
+ tags.push({
586
+ tag: "style",
587
+ props,
588
+ textContent: innerHTML,
589
+ key,
590
+ tagPriority: options?.tagPriority,
591
+ });
592
+ }
593
+ }
594
+
595
+ return tags;
596
+ }
597
+
598
+ /** Escapes HTML entities in attribute values. */
599
+ function escapeAttr(str: string) {
600
+ return str
601
+ .replace(/&/g, "&amp;")
602
+ .replace(/"/g, "&quot;")
603
+ .replace(/</g, "&lt;")
604
+ .replace(/>/g, "&gt;");
605
+ }
606
+
607
+ /** Escapes HTML content. */
608
+ function escapeHtml(str: string) {
609
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
610
+ }
611
+
612
+ /** Renders a HeadTag to HTML string. */
613
+ export function tagToHtml(tag: HeadTag) {
614
+ const attrs = Object.entries(tag.props)
615
+ .filter(([_, v]) => v !== undefined && v !== null && v !== false)
616
+ .map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(String(v))}"`))
617
+ .join(" ");
618
+
619
+ const attrStr = attrs ? ` ${attrs}` : "";
620
+
621
+ if (SELF_CLOSING_TAGS.has(tag.tag)) {
622
+ return `<${tag.tag}${attrStr}>`;
623
+ }
624
+
625
+ const content = tag.textContent
626
+ ? tag.tag === "script" || tag.tag === "style"
627
+ ? tag.textContent // Don't escape script/style content
628
+ : escapeHtml(tag.textContent)
629
+ : "";
630
+
631
+ return `<${tag.tag}${attrStr}>${content}</${tag.tag}>`;
632
+ }
633
+
634
+ /** Registers head tags (works on both server and client). */
635
+ export function useHead(input: HeadInput, options?: HeadEntryOptions) {
636
+ const ctx = getHeadContext();
637
+ const entry = ctx.push(input, options);
638
+
639
+ // On client, apply immediately
640
+ if (typeof window !== "undefined") {
641
+ applyHeadToDOM(ctx.resolveTags());
642
+ }
643
+
644
+ return entry;
645
+ }
646
+
647
+ /** Managed head tags signal for client-side reactivity. */
648
+ const managedTags: Signal<Set<Element>> = signal(new Set());
649
+
650
+ /** Applies head tags to the DOM. */
651
+ function applyHeadToDOM(tags: HeadTag[]) {
652
+ if (typeof document === "undefined") return;
653
+
654
+ const head = document.head;
655
+ const newManagedTags = new Set<Element>();
656
+ let insertionRange: Range | null = null;
657
+
658
+ const getInsertionRange = () => {
659
+ if (insertionRange) return insertionRange;
660
+ const range = document.createRange();
661
+ const firstManaged = head.querySelector("[data-sf-head]");
662
+ if (firstManaged) {
663
+ range.setStartBefore(firstManaged);
664
+ range.collapse(true);
665
+ } else {
666
+ range.selectNodeContents(head);
667
+ range.collapse(false);
668
+ }
669
+ insertionRange = range;
670
+ return range;
671
+ };
672
+
673
+ // Track existing managed elements
674
+ const existingByKey = new Map<string, Element>();
675
+ for (const el of managedTags.value) {
676
+ const key = el.getAttribute("data-sf-head");
677
+ if (key) {
678
+ existingByKey.set(key, el);
679
+ }
680
+ }
681
+
682
+ for (const tag of tags) {
683
+ // Skip title - handled separately via document.title to avoid duplicates
684
+ if (tag.tag === "title") continue;
685
+
686
+ const key = tag._d || `${tag.tag}:${tag._p}`;
687
+
688
+ // Check for existing managed element with same key
689
+ let existing = existingByKey.get(key);
690
+
691
+ // If not found in managed elements, look for SSR-rendered element to adopt
692
+ if (!existing) {
693
+ existing = findMatchingSSRElement(head, tag) ?? undefined;
694
+ if (existing) {
695
+ // Adopt this SSR element by marking it as managed
696
+ existing.setAttribute("data-sf-head", key);
697
+ }
698
+ }
699
+
700
+ if (existing) {
701
+ // Update existing element
702
+ updateElement(existing, tag);
703
+ newManagedTags.add(existing);
704
+ existingByKey.delete(key);
705
+ } else {
706
+ // Create new element
707
+ const el = createElementFromTag(tag);
708
+ el.setAttribute("data-sf-head", key);
709
+ const range = getInsertionRange();
710
+ range.insertNode(el);
711
+ range.setStartAfter(el);
712
+ range.collapse(true);
713
+ newManagedTags.add(el);
714
+ }
715
+ }
716
+
717
+ // Remove orphaned managed elements
718
+ for (const el of existingByKey.values()) {
719
+ el.remove();
720
+ }
721
+
722
+ // Handle title separately (not managed via data-sf-head)
723
+ const titleTag = tags.find((t) => t.tag === "title");
724
+ if (titleTag?.textContent) {
725
+ document.title = titleTag.textContent;
726
+ }
727
+
728
+ managedTags.value = newManagedTags;
729
+ }
730
+
731
+ /** Applies a resolved list of head tags to the DOM. */
732
+ export function applyHeadTags(tags: HeadTag[]) {
733
+ applyHeadToDOM(tags);
734
+ }
735
+
736
+ /** Finds an SSR-rendered element that matches the given tag for adoption. */
737
+ function findMatchingSSRElement(head: HTMLHeadElement, tag: HeadTag) {
738
+ // Don't try to match elements that are already managed
739
+ const candidates = head.querySelectorAll(`${tag.tag}:not([data-sf-head])`);
740
+
741
+ for (const el of candidates) {
742
+ // For meta tags, match by name, property, or http-equiv
743
+ if (tag.tag === "meta") {
744
+ const name = tag.props.name;
745
+ const property = tag.props.property;
746
+ const httpEquiv = tag.props["http-equiv"];
747
+ const charset = tag.props.charset;
748
+
749
+ if (name && el.getAttribute("name") === name) return el;
750
+ if (property && el.getAttribute("property") === property) return el;
751
+ if (httpEquiv && el.getAttribute("http-equiv") === httpEquiv) return el;
752
+ if (charset !== undefined && el.hasAttribute("charset")) return el;
753
+ }
754
+
755
+ // For link tags, match by rel+href or id
756
+ if (tag.tag === "link") {
757
+ const rel = tag.props.rel;
758
+ const href = tag.props.href;
759
+ const id = tag.props.id;
760
+
761
+ if (id && el.getAttribute("id") === id) return el;
762
+ if (rel === "canonical" && el.getAttribute("rel") === "canonical") return el;
763
+ if (rel && href && el.getAttribute("rel") === rel && el.getAttribute("href") === href)
764
+ return el;
765
+ }
766
+
767
+ // For base tag, there should only be one
768
+ if (tag.tag === "base") return el;
769
+ }
770
+
771
+ return null;
772
+ }
773
+
774
+ /** Creates a DOM element from a HeadTag. */
775
+ function createElementFromTag(tag: HeadTag) {
776
+ const el = document.createElement(tag.tag);
777
+
778
+ for (const [key, value] of Object.entries(tag.props)) {
779
+ if (value === undefined || value === null || value === false) continue;
780
+ if (value === true) {
781
+ el.setAttribute(key, "");
782
+ } else {
783
+ el.setAttribute(key, String(value));
784
+ }
785
+ }
786
+
787
+ if (tag.textContent) {
788
+ el.textContent = tag.textContent;
789
+ }
790
+
791
+ return el;
792
+ }
793
+
794
+ /** Updates an existing DOM element with new tag props. */
795
+ function updateElement(el: Element, tag: HeadTag) {
796
+ // Update attributes
797
+ for (const [key, value] of Object.entries(tag.props)) {
798
+ if (value === undefined || value === null || value === false) {
799
+ el.removeAttribute(key);
800
+ } else if (value === true) {
801
+ el.setAttribute(key, "");
802
+ } else {
803
+ el.setAttribute(key, String(value));
804
+ }
805
+ }
806
+
807
+ // Remove attributes not in new tag
808
+ const newKeys = new Set(Object.keys(tag.props));
809
+ for (const attr of Array.from(el.attributes)) {
810
+ if (!newKeys.has(attr.name) && attr.name !== "data-sf-head") {
811
+ el.removeAttribute(attr.name);
812
+ }
813
+ }
814
+
815
+ // Update content
816
+ if (tag.textContent !== undefined) {
817
+ el.textContent = tag.textContent;
818
+ }
819
+ }
820
+
821
+ /** Marker for head tag injection during streaming. */
822
+ export const HEAD_MARKER = "<!--SOLARFLARE_HEAD-->";
823
+
824
+ /**
825
+ * Head component - renders marker for SSR head injection.
826
+ * Place in your layout's <head> where dynamic head tags should be injected.
827
+ * @example
828
+ * <head>
829
+ * <meta charset="UTF-8" />
830
+ * <Head />
831
+ * </head>
832
+ */
833
+ export function Head() {
834
+ return h("template", {
835
+ "data-sf-head": "",
836
+ dangerouslySetInnerHTML: { __html: HEAD_MARKER },
837
+ });
838
+ }
839
+
840
+ /** Serializes head state for client hydration. */
841
+ export function serializeHeadState() {
842
+ const ctx = getHeadContext();
843
+ const state = {
844
+ entries: ctx.entries.map((e) => ({ input: e.input, options: e.options })),
845
+ titleTemplate: ctx.titleTemplate
846
+ ? typeof ctx.titleTemplate === "function"
847
+ ? ctx.titleTemplate.toString()
848
+ : ctx.titleTemplate
849
+ : undefined,
850
+ htmlAttrs: ctx.htmlAttrs,
851
+ bodyAttrs: ctx.bodyAttrs,
852
+ };
853
+ return JSON.stringify(state);
854
+ }
855
+
856
+ /** Hydrates head state on client. */
857
+ export function hydrateHeadState(serialized: string) {
858
+ try {
859
+ const state = JSON.parse(serialized);
860
+ const ctx = getHeadContext();
861
+ ctx.reset();
862
+
863
+ if (state.titleTemplate) {
864
+ // Note: function templates won't survive serialization properly
865
+ ctx.titleTemplate = state.titleTemplate;
866
+ }
867
+ Object.assign(ctx.htmlAttrs, state.htmlAttrs);
868
+ Object.assign(ctx.bodyAttrs, state.bodyAttrs);
869
+
870
+ for (const entry of state.entries) {
871
+ ctx.push(entry.input, entry.options);
872
+ }
873
+ } catch {
874
+ // Ignore parse errors
875
+ }
876
+ }