@pyreon/head 0.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,131 @@
1
+ import * as _pyreon_core0 from "@pyreon/core";
2
+ import { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core";
3
+
4
+ //#region src/context.d.ts
5
+ interface HeadTag {
6
+ /** HTML tag name */
7
+ tag: "title" | "meta" | "link" | "script" | "style" | "base" | "noscript";
8
+ /**
9
+ * Deduplication key. Tags with the same key replace each other;
10
+ * innermost component (last added) wins.
11
+ * Example: all components setting the page title use key "title".
12
+ */
13
+ key?: string;
14
+ /** HTML attributes for the tag */
15
+ props?: Record<string, string>;
16
+ /** Text content — for <title>, <script>, <style>, <noscript> */
17
+ children?: string;
18
+ }
19
+ interface UseHeadInput {
20
+ title?: string;
21
+ /**
22
+ * Title template — use `%s` as a placeholder for the page title.
23
+ * Applied to the resolved title after deduplication.
24
+ * @example useHead({ titleTemplate: "%s | My App" })
25
+ */
26
+ titleTemplate?: string | ((title: string) => string);
27
+ meta?: Record<string, string>[];
28
+ link?: Record<string, string>[];
29
+ script?: ({
30
+ src?: string;
31
+ children?: string;
32
+ } & Record<string, string | undefined>)[];
33
+ style?: ({
34
+ children: string;
35
+ } & Record<string, string | undefined>)[];
36
+ noscript?: {
37
+ children: string;
38
+ }[];
39
+ /** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
40
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
41
+ base?: Record<string, string>;
42
+ /** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
43
+ htmlAttrs?: Record<string, string>;
44
+ /** Attributes to set on the <body> element (e.g. { class: "dark" }) */
45
+ bodyAttrs?: Record<string, string>;
46
+ }
47
+ interface HeadEntry {
48
+ tags: HeadTag[];
49
+ titleTemplate?: string | ((title: string) => string) | undefined;
50
+ htmlAttrs?: Record<string, string> | undefined;
51
+ bodyAttrs?: Record<string, string> | undefined;
52
+ }
53
+ interface HeadContextValue {
54
+ add(id: symbol, entry: HeadEntry): void;
55
+ remove(id: symbol): void;
56
+ /** Returns deduplicated tags — last-added entry wins per key */
57
+ resolve(): HeadTag[];
58
+ /** Returns the merged titleTemplate (last-added wins) */
59
+ resolveTitleTemplate(): (string | ((title: string) => string)) | undefined;
60
+ /** Returns merged htmlAttrs (later entries override earlier) */
61
+ resolveHtmlAttrs(): Record<string, string>;
62
+ /** Returns merged bodyAttrs (later entries override earlier) */
63
+ resolveBodyAttrs(): Record<string, string>;
64
+ }
65
+ declare function createHeadContext(): HeadContextValue;
66
+ declare const HeadContext: _pyreon_core0.Context<HeadContextValue | null>;
67
+ //#endregion
68
+ //#region src/provider.d.ts
69
+ interface HeadProviderProps extends Props {
70
+ context?: HeadContextValue | undefined;
71
+ children?: VNodeChild;
72
+ }
73
+ /**
74
+ * Provides a HeadContextValue to all descendant components.
75
+ * Wrap your app root with this to enable useHead() throughout the tree.
76
+ *
77
+ * If no `context` prop is passed, a new HeadContext is created automatically.
78
+ *
79
+ * @example
80
+ * // Auto-create context:
81
+ * <HeadProvider><App /></HeadProvider>
82
+ *
83
+ * // Explicit context (e.g. for SSR):
84
+ * const headCtx = createHeadContext()
85
+ * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
86
+ */
87
+ declare const HeadProvider: ComponentFn<HeadProviderProps>;
88
+ //#endregion
89
+ //#region src/ssr.d.ts
90
+ /**
91
+ * Render a Pyreon app to an HTML fragment + a serialized <head> string.
92
+ *
93
+ * The returned `head` string can be injected directly into your HTML template:
94
+ *
95
+ * @example
96
+ * const { html, head } = await renderWithHead(h(App, null))
97
+ * const page = `<!DOCTYPE html>
98
+ * <html>
99
+ * <head>
100
+ * <meta charset="UTF-8" />
101
+ * ${head}
102
+ * </head>
103
+ * <body><div id="app">${html}</div></body>
104
+ * </html>`
105
+ */
106
+ interface RenderWithHeadResult {
107
+ html: string;
108
+ head: string;
109
+ /** Attributes to set on the <html> element */
110
+ htmlAttrs: Record<string, string>;
111
+ /** Attributes to set on the <body> element */
112
+ bodyAttrs: Record<string, string>;
113
+ }
114
+ declare function renderWithHead(app: VNode): Promise<RenderWithHeadResult>;
115
+ //#endregion
116
+ //#region src/use-head.d.ts
117
+ /**
118
+ * Register head tags (title, meta, link, script, style, noscript, base, jsonLd)
119
+ * for the current component.
120
+ *
121
+ * Accepts a static object or a reactive getter:
122
+ * useHead({ title: "My Page", meta: [{ name: "description", content: "..." }] })
123
+ * useHead(() => ({ title: `${count()} items` })) // updates when signal changes
124
+ *
125
+ * Tags are deduplicated by key — innermost component wins.
126
+ * Requires a <HeadProvider> (CSR) or renderWithHead() (SSR) ancestor.
127
+ */
128
+ declare function useHead(input: UseHeadInput | (() => UseHeadInput)): void;
129
+ //#endregion
130
+ export { HeadContext, type HeadContextValue, type HeadEntry, HeadProvider, type HeadProviderProps, type HeadTag, type RenderWithHeadResult, type UseHeadInput, createHeadContext, renderWithHead, useHead };
131
+ //# sourceMappingURL=index2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/context.ts","../../src/provider.tsx","../../src/ssr.ts","../../src/use-head.ts"],"mappings":";;;;UAIiB,OAAA;;EAEf,GAAA;;AAFF;;;;EAQE,GAAA;EAAA;EAEA,KAAA,GAAQ,MAAA;EAAA;EAER,QAAA;AAAA;AAAA,UAGe,YAAA;EACf,KAAA;EAD2B;;;;;EAO3B,aAAA,cAA2B,KAAA;EAC3B,IAAA,GAAO,MAAA;EACP,IAAA,GAAO,MAAA;EACP,MAAA;IAAY,GAAA;IAAc,QAAA;EAAA,IAAsB,MAAA;EAChD,KAAA;IAAW,QAAA;EAAA,IAAqB,MAAA;EAChC,QAAA;IAAa,QAAA;EAAA;EAHb;EAKA,MAAA,GAAS,MAAA,oBAA0B,MAAA;EACnC,IAAA,GAAO,MAAA;EALK;EAOZ,SAAA,GAAY,MAAA;EAPoC;EAShD,SAAA,GAAY,MAAA;AAAA;AAAA,UAKG,SAAA;EACf,IAAA,EAAM,OAAA;EACN,aAAA,cAA2B,KAAA;EAC3B,SAAA,GAAY,MAAA;EACZ,SAAA,GAAY,MAAA;AAAA;AAAA,UAGG,gBAAA;EACf,GAAA,CAAI,EAAA,UAAY,KAAA,EAAO,SAAA;EACvB,MAAA,CAAO,EAAA;EAhBK;EAkBZ,OAAA,IAAW,OAAA;EAhBC;EAkBZ,oBAAA,gBAAoC,KAAA;EAlBlB;EAoBlB,gBAAA,IAAoB,MAAA;EAfI;EAiBxB,gBAAA,IAAoB,MAAA;AAAA;AAAA,iBAGN,iBAAA,CAAA,GAAqB,gBAAA;AAAA,cA4CxB,WAAA,EAAW,aAAA,CAAA,OAAA,CAAA,gBAAA;;;UCtGP,iBAAA,SAA0B,KAAA;EACzC,OAAA,GAAU,gBAAA;EACV,QAAA,GAAW,UAAA;AAAA;;;;;;;;;;ADYb;;;;;cCKa,YAAA,EAAc,WAAA,CAAY,iBAAA;;;;;;ADpBvC;;;;;;;;;;;AAeA;;UEKiB,oBAAA;EACf,IAAA;EACA,IAAA;EFGgD;EEDhD,SAAA,EAAW,MAAA;EFKF;EEHT,SAAA,EAAW,MAAA;AAAA;AAAA,iBAGS,cAAA,CAAe,GAAA,EAAK,KAAA,GAAQ,OAAA,CAAQ,oBAAA;;;;;;AF7B1D;;;;;;;;iBGoEgB,OAAA,CAAQ,KAAA,EAAO,YAAA,UAAsB,YAAA"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@pyreon/head",
3
+ "version": "0.1.0",
4
+ "description": "Head tag management for Pyreon — works in SSR and CSR",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pyreon/pyreon.git",
9
+ "directory": "packages/head"
10
+ },
11
+ "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/head#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/pyreon/pyreon/issues"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "sideEffects": false,
22
+ "type": "module",
23
+ "main": "./lib/index.js",
24
+ "module": "./lib/index.js",
25
+ "types": "./lib/types/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "bun": "./src/index.ts",
29
+ "import": "./lib/index.js",
30
+ "types": "./lib/types/index.d.ts"
31
+ },
32
+ "./provider": {
33
+ "bun": "./src/provider.tsx",
34
+ "import": "./lib/provider.js",
35
+ "types": "./lib/types/provider.d.ts"
36
+ },
37
+ "./use-head": {
38
+ "bun": "./src/use-head.ts",
39
+ "import": "./lib/use-head.js",
40
+ "types": "./lib/types/use-head.d.ts"
41
+ },
42
+ "./ssr": {
43
+ "bun": "./src/ssr.ts",
44
+ "import": "./lib/ssr.js",
45
+ "types": "./lib/types/ssr.d.ts"
46
+ }
47
+ },
48
+ "scripts": {
49
+ "build": "vl_rolldown_build",
50
+ "dev": "vl_rolldown_build-watch",
51
+ "test": "vitest run",
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "bun run build"
54
+ },
55
+ "dependencies": {
56
+ "@pyreon/core": "workspace:*",
57
+ "@pyreon/reactivity": "workspace:*",
58
+ "@pyreon/runtime-server": "workspace:*"
59
+ },
60
+ "devDependencies": {
61
+ "@happy-dom/global-registrator": "*",
62
+ "@pyreon/runtime-dom": "workspace:*"
63
+ }
64
+ }
package/src/context.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { createContext } from "@pyreon/core"
2
+
3
+ // ─── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ export interface HeadTag {
6
+ /** HTML tag name */
7
+ tag: "title" | "meta" | "link" | "script" | "style" | "base" | "noscript"
8
+ /**
9
+ * Deduplication key. Tags with the same key replace each other;
10
+ * innermost component (last added) wins.
11
+ * Example: all components setting the page title use key "title".
12
+ */
13
+ key?: string
14
+ /** HTML attributes for the tag */
15
+ props?: Record<string, string>
16
+ /** Text content — for <title>, <script>, <style>, <noscript> */
17
+ children?: string
18
+ }
19
+
20
+ export interface UseHeadInput {
21
+ title?: string
22
+ /**
23
+ * Title template — use `%s` as a placeholder for the page title.
24
+ * Applied to the resolved title after deduplication.
25
+ * @example useHead({ titleTemplate: "%s | My App" })
26
+ */
27
+ titleTemplate?: string | ((title: string) => string)
28
+ meta?: Record<string, string>[]
29
+ link?: Record<string, string>[]
30
+ script?: ({ src?: string; children?: string } & Record<string, string | undefined>)[]
31
+ style?: ({ children: string } & Record<string, string | undefined>)[]
32
+ noscript?: { children: string }[]
33
+ /** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
34
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[]
35
+ base?: Record<string, string>
36
+ /** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
37
+ htmlAttrs?: Record<string, string>
38
+ /** Attributes to set on the <body> element (e.g. { class: "dark" }) */
39
+ bodyAttrs?: Record<string, string>
40
+ }
41
+
42
+ // ─── Context ──────────────────────────────────────────────────────────────────
43
+
44
+ export interface HeadEntry {
45
+ tags: HeadTag[]
46
+ titleTemplate?: string | ((title: string) => string) | undefined
47
+ htmlAttrs?: Record<string, string> | undefined
48
+ bodyAttrs?: Record<string, string> | undefined
49
+ }
50
+
51
+ export interface HeadContextValue {
52
+ add(id: symbol, entry: HeadEntry): void
53
+ remove(id: symbol): void
54
+ /** Returns deduplicated tags — last-added entry wins per key */
55
+ resolve(): HeadTag[]
56
+ /** Returns the merged titleTemplate (last-added wins) */
57
+ resolveTitleTemplate(): (string | ((title: string) => string)) | undefined
58
+ /** Returns merged htmlAttrs (later entries override earlier) */
59
+ resolveHtmlAttrs(): Record<string, string>
60
+ /** Returns merged bodyAttrs (later entries override earlier) */
61
+ resolveBodyAttrs(): Record<string, string>
62
+ }
63
+
64
+ export function createHeadContext(): HeadContextValue {
65
+ const map = new Map<symbol, HeadEntry>()
66
+ return {
67
+ add(id, entry) {
68
+ map.set(id, entry)
69
+ },
70
+ remove(id) {
71
+ map.delete(id)
72
+ },
73
+ resolve() {
74
+ const keyed = new Map<string, HeadTag>()
75
+ const unkeyed: HeadTag[] = []
76
+ for (const entry of map.values()) {
77
+ for (const tag of entry.tags) {
78
+ if (tag.key) keyed.set(tag.key, tag)
79
+ else unkeyed.push(tag)
80
+ }
81
+ }
82
+ return [...keyed.values(), ...unkeyed]
83
+ },
84
+ resolveTitleTemplate() {
85
+ let template: (string | ((title: string) => string)) | undefined
86
+ for (const entry of map.values()) {
87
+ if (entry.titleTemplate !== undefined) template = entry.titleTemplate
88
+ }
89
+ return template
90
+ },
91
+ resolveHtmlAttrs() {
92
+ const attrs: Record<string, string> = {}
93
+ for (const entry of map.values()) {
94
+ if (entry.htmlAttrs) Object.assign(attrs, entry.htmlAttrs)
95
+ }
96
+ return attrs
97
+ },
98
+ resolveBodyAttrs() {
99
+ const attrs: Record<string, string> = {}
100
+ for (const entry of map.values()) {
101
+ if (entry.bodyAttrs) Object.assign(attrs, entry.bodyAttrs)
102
+ }
103
+ return attrs
104
+ },
105
+ }
106
+ }
107
+
108
+ export const HeadContext = createContext<HeadContextValue | null>(null)
package/src/dom.ts ADDED
@@ -0,0 +1,117 @@
1
+ import type { HeadContextValue } from "./context"
2
+
3
+ const ATTR = "data-pyreon-head"
4
+
5
+ /**
6
+ * Sync the resolved head tags to the real DOM <head>.
7
+ * Uses incremental diffing: matches existing elements by key, patches attributes
8
+ * in-place, adds new elements, and removes stale ones.
9
+ * Also syncs htmlAttrs, bodyAttrs, and applies titleTemplate.
10
+ * No-op on the server (typeof document === "undefined").
11
+ */
12
+ function patchExistingTag(
13
+ found: Element,
14
+ tag: { props: Record<string, unknown>; children: string },
15
+ kept: Set<Element>,
16
+ ): void {
17
+ kept.add(found)
18
+ patchAttrs(found, tag.props as Record<string, string>)
19
+ const content = String(tag.children)
20
+ if (found.textContent !== content) found.textContent = content
21
+ }
22
+
23
+ function createNewTag(tag: {
24
+ tag: string
25
+ props: Record<string, unknown>
26
+ children: string
27
+ key: unknown
28
+ }): void {
29
+ const el = document.createElement(tag.tag)
30
+ el.setAttribute(ATTR, tag.key as string)
31
+ for (const [k, v] of Object.entries(tag.props as Record<string, string>)) {
32
+ el.setAttribute(k, v)
33
+ }
34
+ if (tag.children) el.textContent = tag.children
35
+ document.head.appendChild(el)
36
+ }
37
+
38
+ export function syncDom(ctx: HeadContextValue): void {
39
+ if (typeof document === "undefined") return
40
+
41
+ const tags = ctx.resolve()
42
+ const titleTemplate = ctx.resolveTitleTemplate()
43
+ const existing = document.head.querySelectorAll(`[${ATTR}]`)
44
+ const byKey = new Map<string, Element>()
45
+ for (const el of existing) {
46
+ byKey.set(el.getAttribute(ATTR) as string, el)
47
+ }
48
+
49
+ const kept = new Set<Element>()
50
+
51
+ for (const tag of tags) {
52
+ if (tag.tag === "title") {
53
+ document.title = applyTitleTemplate(String(tag.children), titleTemplate)
54
+ continue
55
+ }
56
+
57
+ const key = tag.key as string
58
+ const found = byKey.get(key)
59
+
60
+ if (found && found.tagName.toLowerCase() === tag.tag) {
61
+ patchExistingTag(found, tag as { props: Record<string, unknown>; children: string }, kept)
62
+ } else {
63
+ createNewTag(
64
+ tag as { tag: string; props: Record<string, unknown>; children: string; key: unknown },
65
+ )
66
+ }
67
+ }
68
+
69
+ for (const el of existing) {
70
+ if (!kept.has(el)) el.remove()
71
+ }
72
+
73
+ syncElementAttrs(document.documentElement, ctx.resolveHtmlAttrs())
74
+ syncElementAttrs(document.body, ctx.resolveBodyAttrs())
75
+ }
76
+
77
+ /** Patch an element's attributes to match the desired props. */
78
+ function patchAttrs(el: Element, props: Record<string, string>): void {
79
+ for (let i = el.attributes.length - 1; i >= 0; i--) {
80
+ const attr = el.attributes[i]
81
+ if (!attr || attr.name === ATTR) continue
82
+ if (!(attr.name in props)) el.removeAttribute(attr.name)
83
+ }
84
+ for (const [k, v] of Object.entries(props)) {
85
+ if (el.getAttribute(k) !== v) el.setAttribute(k, v)
86
+ }
87
+ }
88
+
89
+ function applyTitleTemplate(
90
+ title: string,
91
+ template: string | ((t: string) => string) | undefined,
92
+ ): string {
93
+ if (!template) return title
94
+ if (typeof template === "function") return template(title)
95
+ return template.replace(/%s/g, title)
96
+ }
97
+
98
+ /** Sync pyreon-managed attributes on <html> or <body>. */
99
+ function syncElementAttrs(el: Element, attrs: Record<string, string>): void {
100
+ // Remove previously managed attrs that are no longer present
101
+ const managed = el.getAttribute(`${ATTR}-attrs`)
102
+ if (managed) {
103
+ for (const name of managed.split(",")) {
104
+ if (name && !(name in attrs)) el.removeAttribute(name)
105
+ }
106
+ }
107
+ const keys: string[] = []
108
+ for (const [k, v] of Object.entries(attrs)) {
109
+ keys.push(k)
110
+ if (el.getAttribute(k) !== v) el.setAttribute(k, v)
111
+ }
112
+ if (keys.length > 0) {
113
+ el.setAttribute(`${ATTR}-attrs`, keys.join(","))
114
+ } else if (managed) {
115
+ el.removeAttribute(`${ATTR}-attrs`)
116
+ }
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type { HeadContextValue, HeadEntry, HeadTag, UseHeadInput } from "./context"
2
+ export { createHeadContext, HeadContext } from "./context"
3
+ export type { HeadProviderProps } from "./provider"
4
+ export { HeadProvider } from "./provider"
5
+ export type { RenderWithHeadResult } from "./ssr"
6
+ export { renderWithHead } from "./ssr"
7
+ export { useHead } from "./use-head"
@@ -0,0 +1,35 @@
1
+ import type { ComponentFn, Props, VNodeChild } from "@pyreon/core"
2
+ import { onUnmount, popContext, pushContext } from "@pyreon/core"
3
+ import type { HeadContextValue } from "./context"
4
+ import { createHeadContext, HeadContext } from "./context"
5
+
6
+ export interface HeadProviderProps extends Props {
7
+ context?: HeadContextValue | undefined
8
+ children?: VNodeChild
9
+ }
10
+
11
+ /**
12
+ * Provides a HeadContextValue to all descendant components.
13
+ * Wrap your app root with this to enable useHead() throughout the tree.
14
+ *
15
+ * If no `context` prop is passed, a new HeadContext is created automatically.
16
+ *
17
+ * @example
18
+ * // Auto-create context:
19
+ * <HeadProvider><App /></HeadProvider>
20
+ *
21
+ * // Explicit context (e.g. for SSR):
22
+ * const headCtx = createHeadContext()
23
+ * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
24
+ */
25
+ export const HeadProvider: ComponentFn<HeadProviderProps> = (props) => {
26
+ const ctx = props.context ?? createHeadContext()
27
+ // Push context frame synchronously (before children mount) so all descendants
28
+ // can read HeadContext via useContext(). Pop on unmount for correct cleanup.
29
+ const frame = new Map([[HeadContext.id, ctx]])
30
+ pushContext(frame)
31
+ onUnmount(() => popContext())
32
+
33
+ const ch = props.children
34
+ return (typeof ch === "function" ? (ch as () => VNodeChild)() : ch) as ReturnType<ComponentFn>
35
+ }
package/src/ssr.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ComponentFn, VNode } from "@pyreon/core"
2
+ import { h, pushContext } from "@pyreon/core"
3
+ import { renderToString } from "@pyreon/runtime-server"
4
+ import type { HeadTag } from "./context"
5
+ import { createHeadContext, HeadContext } from "./context"
6
+
7
+ const VOID_TAGS = new Set(["meta", "link", "base"])
8
+
9
+ /**
10
+ * Render a Pyreon app to an HTML fragment + a serialized <head> string.
11
+ *
12
+ * The returned `head` string can be injected directly into your HTML template:
13
+ *
14
+ * @example
15
+ * const { html, head } = await renderWithHead(h(App, null))
16
+ * const page = `<!DOCTYPE html>
17
+ * <html>
18
+ * <head>
19
+ * <meta charset="UTF-8" />
20
+ * ${head}
21
+ * </head>
22
+ * <body><div id="app">${html}</div></body>
23
+ * </html>`
24
+ */
25
+ export interface RenderWithHeadResult {
26
+ html: string
27
+ head: string
28
+ /** Attributes to set on the <html> element */
29
+ htmlAttrs: Record<string, string>
30
+ /** Attributes to set on the <body> element */
31
+ bodyAttrs: Record<string, string>
32
+ }
33
+
34
+ export async function renderWithHead(app: VNode): Promise<RenderWithHeadResult> {
35
+ const ctx = createHeadContext()
36
+
37
+ // HeadInjector runs inside renderToString's ALS scope, so pushContext reaches
38
+ // the per-request context stack rather than the module-level fallback stack.
39
+ function HeadInjector(): VNode {
40
+ pushContext(new Map([[HeadContext.id, ctx]]))
41
+ return app
42
+ }
43
+
44
+ const html = await renderToString(h(HeadInjector as ComponentFn, null))
45
+ const titleTemplate = ctx.resolveTitleTemplate()
46
+ const head = ctx
47
+ .resolve()
48
+ .map((tag) => serializeTag(tag, titleTemplate))
49
+ .join("\n ")
50
+ return {
51
+ html,
52
+ head,
53
+ htmlAttrs: ctx.resolveHtmlAttrs(),
54
+ bodyAttrs: ctx.resolveBodyAttrs(),
55
+ }
56
+ }
57
+
58
+ function serializeTag(tag: HeadTag, titleTemplate?: string | ((title: string) => string)): string {
59
+ if (tag.tag === "title") {
60
+ const raw = tag.children || ""
61
+ const title = titleTemplate
62
+ ? typeof titleTemplate === "function"
63
+ ? titleTemplate(raw)
64
+ : titleTemplate.replace(/%s/g, raw)
65
+ : raw
66
+ return `<title>${esc(title)}</title>`
67
+ }
68
+ const props = tag.props as Record<string, string> | undefined
69
+ const attrs = props
70
+ ? Object.entries(props)
71
+ .map(([k, v]) => `${k}="${esc(v)}"`)
72
+ .join(" ")
73
+ : ""
74
+ const open = attrs ? `<${tag.tag} ${attrs}` : `<${tag.tag}`
75
+ if (VOID_TAGS.has(tag.tag)) return `${open} />`
76
+ const content = tag.children || ""
77
+ // Escape sequences that could break out of script/style/noscript blocks:
78
+ // 1. Closing tags like </script> — use Unicode escape in the slash
79
+ // 2. HTML comment openers <!-- that could confuse parsers
80
+ const body = content.replace(/<\/(script|style|noscript)/gi, "<\\/$1").replace(/<!--/g, "<\\!--")
81
+ return `${open}>${body}</${tag.tag}>`
82
+ }
83
+
84
+ function esc(s: string): string {
85
+ return s
86
+ .replace(/&/g, "&amp;")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;")
89
+ .replace(/"/g, "&quot;")
90
+ }