@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.
- package/package.json +52 -0
- package/readme.md +183 -0
- package/src/ast.ts +316 -0
- package/src/build.bundle-client.ts +404 -0
- package/src/build.bundle-server.ts +131 -0
- package/src/build.bundle.ts +48 -0
- package/src/build.emit-manifests.ts +25 -0
- package/src/build.hmr-entry.ts +88 -0
- package/src/build.scan.ts +182 -0
- package/src/build.ts +227 -0
- package/src/build.validate.ts +63 -0
- package/src/client.hmr.ts +78 -0
- package/src/client.styles.ts +68 -0
- package/src/client.ts +190 -0
- package/src/codemod.ts +688 -0
- package/src/console-forward.ts +254 -0
- package/src/critical-css.ts +103 -0
- package/src/devtools-json.ts +52 -0
- package/src/diff-dom-streaming.ts +406 -0
- package/src/early-flush.ts +125 -0
- package/src/early-hints.ts +83 -0
- package/src/fetch.ts +44 -0
- package/src/fs.ts +11 -0
- package/src/head.ts +876 -0
- package/src/hmr.ts +647 -0
- package/src/hydration.ts +238 -0
- package/src/manifest.runtime.ts +25 -0
- package/src/manifest.ts +23 -0
- package/src/paths.ts +96 -0
- package/src/render-priority.ts +69 -0
- package/src/route-cache.ts +163 -0
- package/src/router-deferred.ts +85 -0
- package/src/router-stream.ts +65 -0
- package/src/router.ts +535 -0
- package/src/runtime.ts +32 -0
- package/src/serialize.ts +38 -0
- package/src/server.hmr.ts +67 -0
- package/src/server.styles.ts +42 -0
- package/src/server.ts +480 -0
- package/src/solarflare.d.ts +101 -0
- package/src/speculation-rules.ts +171 -0
- package/src/store.ts +78 -0
- package/src/stream-assets.ts +135 -0
- package/src/stylesheets.ts +222 -0
- package/src/worker.config.ts +243 -0
- package/src/worker.ts +542 -0
- 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, "&")
|
|
602
|
+
.replace(/"/g, """)
|
|
603
|
+
.replace(/</g, "<")
|
|
604
|
+
.replace(/>/g, ">");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Escapes HTML content. */
|
|
608
|
+
function escapeHtml(str: string) {
|
|
609
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|