@pyreon/head 0.24.4 → 0.24.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/head",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
4
4
  "description": "Head tag management for Pyreon — works in SSR and CSR",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/head#readme",
6
6
  "bugs": {
@@ -15,7 +15,6 @@
15
15
  "files": [
16
16
  "lib",
17
17
  "!lib/**/*.map",
18
- "src",
19
18
  "README.md",
20
19
  "LICENSE"
21
20
  ],
@@ -26,27 +25,22 @@
26
25
  "types": "./lib/types/index.d.ts",
27
26
  "exports": {
28
27
  ".": {
29
- "bun": "./src/index.ts",
30
28
  "import": "./lib/index.js",
31
29
  "types": "./lib/types/index.d.ts"
32
30
  },
33
31
  "./context": {
34
- "bun": "./src/context.ts",
35
32
  "import": "./lib/context.js",
36
33
  "types": "./lib/types/context.d.ts"
37
34
  },
38
35
  "./provider": {
39
- "bun": "./src/provider.ts",
40
36
  "import": "./lib/provider.js",
41
37
  "types": "./lib/types/provider.d.ts"
42
38
  },
43
39
  "./use-head": {
44
- "bun": "./src/use-head.ts",
45
40
  "import": "./lib/use-head.js",
46
41
  "types": "./lib/types/use-head.d.ts"
47
42
  },
48
43
  "./ssr": {
49
- "bun": "./src/ssr.ts",
50
44
  "import": "./lib/ssr.js",
51
45
  "types": "./lib/types/ssr.d.ts"
52
46
  }
@@ -64,15 +58,15 @@
64
58
  "prepublishOnly": "bun run build"
65
59
  },
66
60
  "dependencies": {
67
- "@pyreon/core": "^0.24.4",
68
- "@pyreon/reactivity": "^0.24.4",
69
- "@pyreon/runtime-server": "^0.24.4"
61
+ "@pyreon/core": "^0.24.6",
62
+ "@pyreon/reactivity": "^0.24.6",
63
+ "@pyreon/runtime-server": "^0.24.6"
70
64
  },
71
65
  "devDependencies": {
72
66
  "@happy-dom/global-registrator": "^20.8.9",
73
67
  "@pyreon/manifest": "0.13.1",
74
- "@pyreon/runtime-dom": "^0.24.4",
75
- "@pyreon/runtime-server": "^0.24.4",
68
+ "@pyreon/runtime-dom": "^0.24.6",
69
+ "@pyreon/runtime-server": "^0.24.6",
76
70
  "@pyreon/test-utils": "^0.13.11",
77
71
  "@vitest/browser-playwright": "^4.1.4"
78
72
  },
package/src/context.ts DELETED
@@ -1,282 +0,0 @@
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
- // ─── Strict tag types ────────────────────────────────────────────────────────
21
-
22
- /** Standard `<meta>` tag attributes. Catches typos like `{ naem: "description" }`. */
23
- export interface MetaTag {
24
- /** Standard meta name (e.g. "description", "viewport", "robots") */
25
- name?: string
26
- /** Open Graph / social property (e.g. "og:title", "twitter:card") */
27
- property?: string
28
- /** HTTP equivalent header (e.g. "refresh", "content-type") */
29
- 'http-equiv'?: string
30
- /** Value associated with name, property, or http-equiv */
31
- content?: string
32
- /** Document character encoding (e.g. "utf-8") */
33
- charset?: string
34
- /** Schema.org itemprop */
35
- itemprop?: string
36
- /** Media condition for applicability (e.g. "(prefers-color-scheme: dark)") */
37
- media?: string
38
- }
39
-
40
- /** Standard `<link>` tag attributes. */
41
- export interface LinkTag {
42
- /** Relationship to the current document (e.g. "stylesheet", "icon", "canonical") */
43
- rel?: string
44
- /** URL of the linked resource */
45
- href?: string
46
- /** Resource type hint for preloading (e.g. "style", "script", "font") */
47
- as?: string
48
- /** MIME type (e.g. "text/css", "image/png") */
49
- type?: string
50
- /** Media query for conditional loading */
51
- media?: string
52
- /** CORS mode */
53
- crossorigin?: string
54
- /** Subresource integrity hash */
55
- integrity?: string
56
- /** Icon sizes (e.g. "32x32", "any") */
57
- sizes?: string
58
- /** Language of the linked resource */
59
- hreflang?: string
60
- /** Title for the link (used for alternate stylesheets) */
61
- title?: string
62
- /** Fetch priority hint */
63
- fetchpriority?: 'high' | 'low' | 'auto'
64
- /** Referrer policy */
65
- referrerpolicy?: string
66
- /** Image source set for preloading responsive images */
67
- imagesrcset?: string
68
- /** Image sizes for preloading responsive images */
69
- imagesizes?: string
70
- /** Disable the resource (for stylesheets) */
71
- disabled?: string
72
- /** Color for mask-icon */
73
- color?: string
74
- }
75
-
76
- /** Standard `<script>` tag attributes. */
77
- export interface ScriptTag {
78
- /** External script URL */
79
- src?: string
80
- /** Script MIME type or module type (e.g. "module", "importmap") */
81
- type?: string
82
- /** Load asynchronously */
83
- async?: string
84
- /** Defer execution until document is parsed */
85
- defer?: string
86
- /** CORS mode */
87
- crossorigin?: string
88
- /** Subresource integrity hash */
89
- integrity?: string
90
- /** Exclude from module-supporting browsers */
91
- nomodule?: string
92
- /** Referrer policy */
93
- referrerpolicy?: string
94
- /** Fetch priority hint */
95
- fetchpriority?: string
96
- /** Inline script content */
97
- children?: string
98
- }
99
-
100
- /** Standard `<style>` tag attributes. */
101
- export interface StyleTag {
102
- /** Inline CSS content (required) */
103
- children: string
104
- /** Media query for conditional styles */
105
- media?: string
106
- /** Nonce for CSP */
107
- nonce?: string
108
- /** Title for alternate stylesheets */
109
- title?: string
110
- /** Render-blocking behavior */
111
- blocking?: string
112
- }
113
-
114
- /**
115
- * How eagerly the browser should act on a speculation rule.
116
- * Per the W3C Speculation Rules spec.
117
- */
118
- export type SpeculationEagerness = 'immediate' | 'eager' | 'moderate' | 'conservative'
119
-
120
- /**
121
- * A single speculation rule (one entry in a `prefetch` / `prerender` list).
122
- *
123
- * - `source: 'list'` + `urls` — prefetch/prerender these explicit URLs.
124
- * - `source: 'document'` + `where` — let the browser pick links from the
125
- * current document that match the predicate (e.g. a CSS selector via
126
- * `{ selector_matches: '.router-link' }`).
127
- */
128
- export interface SpeculationRule {
129
- /** `'list'` (explicit `urls`) or `'document'` (predicate-driven). */
130
- source?: 'list' | 'document'
131
- /** Same-origin URLs to prefetch/prerender (for `source: 'list'`). */
132
- urls?: string[]
133
- /** Document predicate (for `source: 'document'`) — e.g. `{ selector_matches: 'a.next' }`. */
134
- where?: Record<string, unknown>
135
- /** When the browser should fetch — defaults to the browser's per-source default. */
136
- eagerness?: SpeculationEagerness
137
- /** Capability requirements, e.g. `['anonymous-client-ip-when-cross-origin']`. */
138
- requires?: string[]
139
- /** Referrer policy for the speculative request. */
140
- referrer_policy?: string
141
- }
142
-
143
- /**
144
- * Declarative Speculation Rules — emitted as a single
145
- * `<script type="speculationrules">` tag. Supported browsers prefetch or
146
- * fully prerender the next document(s) so navigation is instant. Inert in
147
- * non-supporting browsers (no polyfill needed). Opt-in: only emitted when
148
- * `useHead({ speculationRules })` is called.
149
- *
150
- * @see https://developer.mozilla.org/docs/Web/API/Speculation_Rules_API
151
- */
152
- export interface SpeculationRules {
153
- /** Lightweight: fetch the response, no rendering. */
154
- prefetch?: SpeculationRule[]
155
- /** Heavy: fully render the next document in the background. */
156
- prerender?: SpeculationRule[]
157
- }
158
-
159
- /** Standard `<base>` tag attributes. */
160
- export interface BaseTag {
161
- /** Base URL for relative URLs in the document */
162
- href?: string
163
- /** Default target for links and forms */
164
- target?: '_blank' | '_self' | '_parent' | '_top'
165
- }
166
-
167
- export interface UseHeadInput {
168
- title?: string
169
- /**
170
- * Title template — use `%s` as a placeholder for the page title.
171
- * Applied to the resolved title after deduplication.
172
- * @example useHead({ titleTemplate: "%s | My App" })
173
- */
174
- titleTemplate?: string | ((title: string) => string)
175
- meta?: MetaTag[]
176
- link?: LinkTag[]
177
- script?: ScriptTag[]
178
- style?: StyleTag[]
179
- noscript?: { children: string }[]
180
- /** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
181
- jsonLd?: Record<string, unknown> | Record<string, unknown>[]
182
- /**
183
- * Convenience: emits a `<script type="speculationrules">` tag with the
184
- * JSON.stringify'd rules. Supported browsers prefetch/prerender the next
185
- * document(s) for near-instant navigation; inert elsewhere. Opt-in.
186
- * @example useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/about'], eagerness: 'moderate' }] } })
187
- */
188
- speculationRules?: SpeculationRules
189
- base?: BaseTag
190
- /** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
191
- htmlAttrs?: Record<string, string>
192
- /** Attributes to set on the <body> element (e.g. { class: "dark" }) */
193
- bodyAttrs?: Record<string, string>
194
- }
195
-
196
- // ─── Context ──────────────────────────────────────────────────────────────────
197
-
198
- export interface HeadEntry {
199
- tags: HeadTag[]
200
- titleTemplate?: string | ((title: string) => string) | undefined
201
- htmlAttrs?: Record<string, string> | undefined
202
- bodyAttrs?: Record<string, string> | undefined
203
- }
204
-
205
- export interface HeadContextValue {
206
- add(id: symbol, entry: HeadEntry): void
207
- remove(id: symbol): void
208
- /** Returns deduplicated tags — last-added entry wins per key */
209
- resolve(): HeadTag[]
210
- /** Returns the merged titleTemplate (last-added wins) */
211
- resolveTitleTemplate(): (string | ((title: string) => string)) | undefined
212
- /** Returns merged htmlAttrs (later entries override earlier) */
213
- resolveHtmlAttrs(): Record<string, string>
214
- /** Returns merged bodyAttrs (later entries override earlier) */
215
- resolveBodyAttrs(): Record<string, string>
216
- }
217
-
218
- export function createHeadContext(): HeadContextValue {
219
- const map = new Map<symbol, HeadEntry>()
220
-
221
- // ── Cached resolve ───────────────────────────────────────────────────────
222
- let dirty = true
223
- let cachedTags: HeadTag[] = []
224
- let cachedTitleTemplate: (string | ((title: string) => string)) | undefined
225
- let cachedHtmlAttrs: Record<string, string> = {}
226
- let cachedBodyAttrs: Record<string, string> = {}
227
-
228
- function rebuild(): void {
229
- if (!dirty) return
230
- dirty = false
231
-
232
- const keyed = new Map<string, HeadTag>()
233
- const unkeyed: HeadTag[] = []
234
- let titleTemplate: (string | ((title: string) => string)) | undefined
235
- const htmlAttrs: Record<string, string> = {}
236
- const bodyAttrs: Record<string, string> = {}
237
-
238
- for (const entry of map.values()) {
239
- for (const tag of entry.tags) {
240
- if (tag.key) keyed.set(tag.key, tag)
241
- else unkeyed.push(tag)
242
- }
243
- if (entry.titleTemplate !== undefined) titleTemplate = entry.titleTemplate
244
- if (entry.htmlAttrs) Object.assign(htmlAttrs, entry.htmlAttrs)
245
- if (entry.bodyAttrs) Object.assign(bodyAttrs, entry.bodyAttrs)
246
- }
247
-
248
- cachedTags = [...keyed.values(), ...unkeyed]
249
- cachedTitleTemplate = titleTemplate
250
- cachedHtmlAttrs = htmlAttrs
251
- cachedBodyAttrs = bodyAttrs
252
- }
253
-
254
- return {
255
- add(id, entry) {
256
- map.set(id, entry)
257
- dirty = true
258
- },
259
- remove(id) {
260
- map.delete(id)
261
- dirty = true
262
- },
263
- resolve() {
264
- rebuild()
265
- return cachedTags
266
- },
267
- resolveTitleTemplate() {
268
- rebuild()
269
- return cachedTitleTemplate
270
- },
271
- resolveHtmlAttrs() {
272
- rebuild()
273
- return cachedHtmlAttrs
274
- },
275
- resolveBodyAttrs() {
276
- rebuild()
277
- return cachedBodyAttrs
278
- },
279
- }
280
- }
281
-
282
- export const HeadContext = createContext<HeadContextValue | null>(null)
package/src/dom.ts DELETED
@@ -1,147 +0,0 @@
1
- import type { HeadContextValue } from './context'
2
-
3
- const ATTR = 'data-pyreon-head'
4
-
5
- /** Tracks managed elements by key — avoids querySelectorAll on every sync */
6
- const managedElements = new Map<string, Element>()
7
-
8
- /**
9
- * Sync the resolved head tags to the real DOM <head>.
10
- * Uses incremental diffing: matches existing elements by key, patches attributes
11
- * in-place, adds new elements, and removes stale ones.
12
- * Also syncs htmlAttrs, bodyAttrs, and applies titleTemplate.
13
- * No-op on the server (typeof document === "undefined").
14
- */
15
- function patchExistingTag(
16
- found: Element,
17
- tag: { props: Record<string, unknown>; children: string },
18
- kept: Set<string>,
19
- ): void {
20
- kept.add(found.getAttribute(ATTR) as string)
21
- patchAttrs(found, tag.props as Record<string, string>)
22
- const content = String(tag.children)
23
- if (found.textContent !== content) found.textContent = content
24
- }
25
-
26
- function createNewTag(tag: {
27
- tag: string
28
- props: Record<string, unknown>
29
- children: string
30
- key: unknown
31
- }): void {
32
- // SSR guard: only ever reached via the (also-guarded) `syncDom`, but
33
- // keep the guard local so the contract is self-evident and SSR-safe
34
- // even if a future caller invokes this directly.
35
- if (typeof document === 'undefined') return
36
- const el = document.createElement(tag.tag)
37
- const key = tag.key as string
38
- el.setAttribute(ATTR, key)
39
- for (const [k, v] of Object.entries(tag.props as Record<string, string>)) {
40
- el.setAttribute(k, v)
41
- }
42
- if (tag.children) el.textContent = tag.children
43
- document.head.appendChild(el)
44
- managedElements.set(key, el)
45
- }
46
-
47
- export function syncDom(ctx: HeadContextValue): void {
48
- if (typeof document === 'undefined') return
49
-
50
- const tags = ctx.resolve()
51
- const titleTemplate = ctx.resolveTitleTemplate()
52
-
53
- // Seed from DOM on first sync, or re-seed if DOM was reset (e.g. between tests)
54
- let needsSeed = managedElements.size === 0
55
- if (!needsSeed) {
56
- // Check if a tracked element is still in the DOM
57
- const sample = managedElements.values().next().value
58
- if (sample && !sample.isConnected) {
59
- managedElements.clear()
60
- needsSeed = true
61
- }
62
- }
63
- if (needsSeed) {
64
- const existing = document.head.querySelectorAll(`[${ATTR}]`)
65
- for (const el of existing) {
66
- managedElements.set(el.getAttribute(ATTR) as string, el)
67
- }
68
- }
69
-
70
- const kept = new Set<string>()
71
-
72
- for (const tag of tags) {
73
- if (tag.tag === 'title') {
74
- document.title = applyTitleTemplate(String(tag.children), titleTemplate)
75
- continue
76
- }
77
-
78
- const key = tag.key as string
79
- const found = managedElements.get(key)
80
-
81
- if (found && found.tagName.toLowerCase() === tag.tag) {
82
- patchExistingTag(found, tag as { props: Record<string, unknown>; children: string }, kept)
83
- } else {
84
- if (found) {
85
- found.remove()
86
- managedElements.delete(key)
87
- }
88
- createNewTag(
89
- tag as { tag: string; props: Record<string, unknown>; children: string; key: unknown },
90
- )
91
- kept.add(key)
92
- }
93
- }
94
-
95
- // Remove stale elements
96
- for (const [key, el] of managedElements) {
97
- if (!kept.has(key)) {
98
- el.remove()
99
- managedElements.delete(key)
100
- }
101
- }
102
-
103
- syncElementAttrs(document.documentElement, ctx.resolveHtmlAttrs())
104
- syncElementAttrs(document.body, ctx.resolveBodyAttrs())
105
- }
106
-
107
- /** Patch an element's attributes to match the desired props. */
108
- function patchAttrs(el: Element, props: Record<string, string>): void {
109
- for (let i = el.attributes.length - 1; i >= 0; i--) {
110
- const attr = el.attributes[i]
111
- if (!attr || attr.name === ATTR) continue
112
- if (!(attr.name in props)) el.removeAttribute(attr.name)
113
- }
114
- for (const [k, v] of Object.entries(props)) {
115
- if (el.getAttribute(k) !== v) el.setAttribute(k, v)
116
- }
117
- }
118
-
119
- function applyTitleTemplate(
120
- title: string,
121
- template: string | ((t: string) => string) | undefined,
122
- ): string {
123
- if (!template) return title
124
- if (typeof template === 'function') return template(title)
125
- return template.replace(/%s/g, title)
126
- }
127
-
128
- /** Sync pyreon-managed attributes on <html> or <body>. */
129
- function syncElementAttrs(el: Element, attrs: Record<string, string>): void {
130
- // Remove previously managed attrs that are no longer present
131
- const managed = el.getAttribute(`${ATTR}-attrs`)
132
- if (managed) {
133
- for (const name of managed.split(',')) {
134
- if (name && !(name in attrs)) el.removeAttribute(name)
135
- }
136
- }
137
- const keys: string[] = []
138
- for (const [k, v] of Object.entries(attrs)) {
139
- keys.push(k)
140
- if (el.getAttribute(k) !== v) el.setAttribute(k, v)
141
- }
142
- if (keys.length > 0) {
143
- el.setAttribute(`${ATTR}-attrs`, keys.join(','))
144
- } else if (managed) {
145
- el.removeAttribute(`${ATTR}-attrs`)
146
- }
147
- }
package/src/index.ts DELETED
@@ -1,18 +0,0 @@
1
- export type {
2
- BaseTag,
3
- HeadContextValue,
4
- HeadEntry,
5
- HeadTag,
6
- LinkTag,
7
- MetaTag,
8
- ScriptTag,
9
- SpeculationEagerness,
10
- SpeculationRule,
11
- SpeculationRules,
12
- StyleTag,
13
- UseHeadInput,
14
- } from './context'
15
- export { createHeadContext, HeadContext } from './context'
16
- export type { HeadProviderProps } from './provider'
17
- export { HeadProvider } from './provider'
18
- export { useHead } from './use-head'
package/src/manifest.ts DELETED
@@ -1,153 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- export default defineManifest({
4
- name: '@pyreon/head',
5
- title: 'Head Management',
6
- tagline:
7
- 'Reactive `<head>` tag management — useHead(), HeadProvider, renderWithHead() for SSR',
8
- description:
9
- 'Reactive head tag management for Pyreon — `useHead()` collects title, meta, link, script, style, noscript, base, jsonLd entries from any component in the tree (static or signal-driven). `HeadProvider` collects them on the client and syncs to the live `<head>` element; `renderWithHead()` collects them on the server and returns the serialized HTML alongside the rendered app.',
10
- category: 'browser',
11
- features: [
12
- 'useHead(input | () => input) — register head tags from any component',
13
- 'Reactive: pass a function to re-register on signal change',
14
- 'Title templates with %s placeholder or function form',
15
- 'HeadProvider for client-side DOM sync',
16
- 'renderWithHead() for SSR — returns html + head string',
17
- 'Keyed deduplication — innermost component wins per key',
18
- 'JSON-LD shorthand: `jsonLd: {...}` auto-wraps as `<script type="application/ld+json">`',
19
- 'Speculation Rules shorthand: `speculationRules: {...}` auto-wraps as `<script type="speculationrules">` — native browser prefetch/prerender, opt-in',
20
- ],
21
- longExample: `import { useHead, HeadProvider } from '@pyreon/head'
22
- import { renderWithHead } from '@pyreon/head'
23
- import { mount } from '@pyreon/runtime-dom'
24
-
25
- // Static head tags from any component
26
- function ProfilePage() {
27
- useHead({
28
- title: 'My Profile',
29
- meta: [{ name: 'description', content: 'User profile page' }],
30
- link: [{ rel: 'canonical', href: 'https://example.com/profile' }],
31
- })
32
- return <div>profile body</div>
33
- }
34
-
35
- // Reactive head — pass a function so signal reads re-register on change
36
- function ReactiveTitle() {
37
- useHead(() => ({
38
- title: \`\${username()} — Profile\`,
39
- meta: [{ property: 'og:title', content: username() }],
40
- }))
41
- return null
42
- }
43
-
44
- // Client setup
45
- mount(
46
- <HeadProvider>
47
- <App />
48
- </HeadProvider>,
49
- document.getElementById('app')!,
50
- )
51
-
52
- // Server setup — collects every useHead() call and serializes the head
53
- const { html, head, htmlAttrs, bodyAttrs } = await renderWithHead(<App />)
54
- const document = \`<!doctype html><html\${htmlAttrs}><head>\${head}</head><body\${bodyAttrs}>\${html}</body></html>\``,
55
- api: [
56
- {
57
- name: 'useHead',
58
- kind: 'hook',
59
- signature: 'useHead(input: UseHeadInput | (() => UseHeadInput)): void',
60
- summary:
61
- 'Register head tags from any component in the tree. Pass a static `UseHeadInput` object for one-shot registration, or a `() => UseHeadInput` thunk for reactive re-registration when signal reads inside the thunk change. Calling `useHead()` outside a `HeadProvider` ancestor (CSR) or `renderWithHead()` invocation (SSR) is a silent no-op — it does not throw.',
62
- example: `// Static:
63
- useHead({ title: "My Page", meta: [{ name: "description", content: "..." }] })
64
-
65
- // Reactive (updates when signals change):
66
- useHead(() => ({
67
- title: \`\${username()} — Profile\`,
68
- meta: [{ property: "og:title", content: username() }]
69
- }))`,
70
- mistakes: [
71
- 'Using `${...}` in a `titleTemplate` string — the placeholder is `%s` (or pass a function form `(title) => …`)',
72
- 'Calling `useHead()` outside any `HeadProvider` / `renderWithHead()` boundary — silent no-op, the entries simply go nowhere',
73
- 'Wrapping the input in `computed()` instead of a thunk — pass a plain `() => ({...})` arrow; `useHead` registers its own effect',
74
- 'Expecting `</script>` inside an inline script body to render verbatim — the SSR escaper rewrites it as `<\\/script>` to prevent breaking out of the inline tag',
75
- 'Treating `speculationRules` as a guaranteed perf win — it is a declarative HINT (like `<link rel=prefetch>`); supported browsers prefetch/prerender at their own discretion, unsupported ones ignore it. It is opt-in and zero-runtime-JS; it does not replace `RouterLink prefetch` (which warms loader data for client-side nav)',
76
- ],
77
- seeAlso: ['HeadProvider', 'renderWithHead'],
78
- },
79
- {
80
- name: 'HeadProvider',
81
- kind: 'component',
82
- signature: '(props: HeadProviderProps) => VNodeChild',
83
- summary:
84
- 'Context provider that collects every `useHead()` call from descendants. Resolves its context as `props.context ?? outer HeadContext in scope ?? a fresh one`, so a `HeadProvider` mounted INSIDE `renderWithHead()` (or inside another `HeadProvider`) transparently inherits the outer registry instead of shadowing it with a write-only one. On the client it also syncs the resolved tags into the live `document.head`. Mount once near the application root for the canonical CSR shape; the inheritance step makes nested mounts and the SSR-wrapped shape work without manual context plumbing.',
85
- example: `<HeadProvider>{children}</HeadProvider>
86
-
87
- // CSR root — auto-creates a fresh context:
88
- mount(
89
- <HeadProvider>
90
- <App />
91
- </HeadProvider>,
92
- document.getElementById("app")!
93
- )
94
-
95
- // SSR — composes with renderWithHead out of the box (no context prop needed):
96
- const { html, head } = await renderWithHead(
97
- <HeadProvider><App /></HeadProvider>
98
- )
99
-
100
- // Explicit isolation (iframe / micro-frontend boundary):
101
- <HeadProvider context={createHeadContext()}><App /></HeadProvider>`,
102
- mistakes: [
103
- 'Mounting two `HeadProvider` instances at SIBLING roots — each owns an independent context, so a `useHead()` deeper in tree A is invisible to tree B (use a shared `context` prop or merge under a common parent provider)',
104
- 'Forgetting to mount `HeadProvider` (or `renderWithHead`) and expecting `useHead()` to still update `document.head` — silent no-op outside any provider',
105
- 'Assuming a NESTED `HeadProvider` isolates its subtree by default — it does the opposite, inheriting the outer context. Pass `context={createHeadContext()}` explicitly when you genuinely want isolation',
106
- ],
107
- seeAlso: ['useHead', 'renderWithHead', 'createHeadContext'],
108
- },
109
- {
110
- name: 'renderWithHead',
111
- kind: 'function',
112
- signature:
113
- 'renderWithHead(app: VNode): Promise<{ html: string; head: string; htmlAttrs: string; bodyAttrs: string }>',
114
- summary:
115
- 'SSR companion to `HeadProvider`. Renders the app to HTML via `renderToString` while collecting every `useHead()` call from the tree, then serializes the resolved tags into a single `head` string plus separate `htmlAttrs` / `bodyAttrs` strings. Async components that call `useHead()` in their body work — the renderer awaits suspended subtrees before serialization.',
116
- example: `import { renderWithHead } from '@pyreon/head'
117
-
118
- const { html, head, htmlAttrs, bodyAttrs } = await renderWithHead(<App />)
119
- const doc = \`<!doctype html><html\${htmlAttrs}><head>\${head}</head><body\${bodyAttrs}>\${html}</body></html>\``,
120
- mistakes: [
121
- 'Awaiting `renderWithHead` and then NOT splicing `head` into the `<head>` element — every `useHead()` call quietly disappears',
122
- 'Forgetting to interpolate `htmlAttrs` / `bodyAttrs` (the leading space is included in each string) — `htmlAttrs.lang` and `bodyAttrs.class` set via `useHead` won\\\'t reach the DOM',
123
- ],
124
- seeAlso: ['useHead', 'HeadProvider'],
125
- },
126
- {
127
- name: 'createHeadContext',
128
- kind: 'function',
129
- signature: '() => HeadContextValue',
130
- summary:
131
- 'Manual factory for a `HeadContextValue` — only needed when wiring up a custom SSR pipeline that bypasses `renderWithHead`, or when running multiple isolated head contexts in the same process. The value exposes `add` / `remove` / `resolve` / `resolveTitleTemplate` / `resolveHtmlAttrs` / `resolveBodyAttrs` for full programmatic control.',
132
- example: `import { createHeadContext, HeadContext } from '@pyreon/head'
133
-
134
- const ctx = createHeadContext()
135
- provide(HeadContext, ctx)
136
- // ... render tree that calls useHead() ...
137
- const { tags, htmlAttrs, bodyAttrs } = ctx.resolve()`,
138
- seeAlso: ['HeadProvider', 'renderWithHead'],
139
- },
140
- ],
141
- gotchas: [
142
- {
143
- label: 'Key deduplication',
144
- note:
145
- 'Tags with the same key replace each other (innermost wins). Meta keys: `name` → `property` → index. Link keys: `href + rel` → `rel` → index. Script keys: `src` → index. Style and noscript are unkeyed and always accumulated.',
146
- },
147
- {
148
- label: 'Inline script escaping',
149
- note:
150
- 'Script / style / noscript bodies are not HTML-escaped, but the SSR serializer rewrites `</script>` / `</style>` / `</noscript>` and `<!--` to prevent breaking out of the wrapping tag. Inline JSON-LD via `jsonLd: {...}` auto-wraps in `<script type="application/ld+json">` and stringifies the value.',
151
- },
152
- ],
153
- })