@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 +6 -12
- package/src/context.ts +0 -282
- package/src/dom.ts +0 -147
- package/src/index.ts +0 -18
- package/src/manifest.ts +0 -153
- package/src/provider.ts +0 -65
- package/src/ssr.ts +0 -89
- package/src/tests/context-identity.test.ts +0 -126
- package/src/tests/head.browser.test.tsx +0 -251
- package/src/tests/head.test.ts +0 -1313
- package/src/tests/integration.test.tsx +0 -83
- package/src/tests/manifest-snapshot.test.ts +0 -34
- package/src/tests/native-marker.test.ts +0 -9
- package/src/tests/provider-inherits-context.test.tsx +0 -131
- package/src/tests/setup.ts +0 -3
- package/src/use-head.ts +0 -123
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { h } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
-
import type { HeadContextValue } from '../index'
|
|
5
|
-
import { createHeadContext, HeadProvider, useHead } from '../index'
|
|
6
|
-
|
|
7
|
-
// ─── Integration tests ───────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
describe('Head integration — CSR', () => {
|
|
10
|
-
let container: HTMLElement
|
|
11
|
-
let ctx: HeadContextValue
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
container = document.createElement('div')
|
|
15
|
-
document.body.appendChild(container)
|
|
16
|
-
ctx = createHeadContext()
|
|
17
|
-
for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
|
|
18
|
-
document.title = ''
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
container.remove()
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('useHead({ title }) updates document.title', () => {
|
|
26
|
-
function Page() {
|
|
27
|
-
useHead({ title: 'Page' })
|
|
28
|
-
return h('div', null, 'content')
|
|
29
|
-
}
|
|
30
|
-
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
31
|
-
expect(document.title).toBe('Page')
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('useHead({ meta }) injects meta tag into head', () => {
|
|
35
|
-
function Page() {
|
|
36
|
-
useHead({ meta: [{ name: 'description', content: 'Integration test' }] })
|
|
37
|
-
return h('div', null)
|
|
38
|
-
}
|
|
39
|
-
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
40
|
-
const meta = document.head.querySelector('meta[name="description"]')
|
|
41
|
-
expect(meta).not.toBeNull()
|
|
42
|
-
expect(meta?.getAttribute('content')).toBe('Integration test')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('reactive useHead updates title when signal changes', () => {
|
|
46
|
-
const title = signal('Initial')
|
|
47
|
-
function Page() {
|
|
48
|
-
useHead(() => ({ title: title() }))
|
|
49
|
-
return h('div', null)
|
|
50
|
-
}
|
|
51
|
-
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
52
|
-
expect(document.title).toBe('Initial')
|
|
53
|
-
title.set('Updated via signal')
|
|
54
|
-
expect(document.title).toBe('Updated via signal')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('nested components: inner useHead overrides outer title', () => {
|
|
58
|
-
function Inner() {
|
|
59
|
-
useHead({ title: 'Inner Title' })
|
|
60
|
-
return h('span', null)
|
|
61
|
-
}
|
|
62
|
-
function Outer() {
|
|
63
|
-
useHead({ title: 'Outer Title' })
|
|
64
|
-
return h('div', null, h(Inner, null))
|
|
65
|
-
}
|
|
66
|
-
mount(h(HeadProvider, { context: ctx, children: h(Outer, null) }), container)
|
|
67
|
-
expect(document.title).toBe('Inner Title')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('unmount removes head tags', () => {
|
|
71
|
-
function Page() {
|
|
72
|
-
useHead({ meta: [{ name: 'keywords', content: 'pyreon,test' }] })
|
|
73
|
-
return h('div', null)
|
|
74
|
-
}
|
|
75
|
-
const cleanup = mount(
|
|
76
|
-
h(HeadProvider, { context: ctx, children: h(Page, null) }),
|
|
77
|
-
container,
|
|
78
|
-
)
|
|
79
|
-
expect(document.head.querySelector('meta[name="keywords"]')).not.toBeNull()
|
|
80
|
-
cleanup()
|
|
81
|
-
expect(document.head.querySelector('meta[name="keywords"]')).toBeNull()
|
|
82
|
-
})
|
|
83
|
-
})
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
renderApiReferenceEntries,
|
|
3
|
-
renderLlmsFullSection,
|
|
4
|
-
renderLlmsTxtLine,
|
|
5
|
-
} from '@pyreon/manifest'
|
|
6
|
-
import manifest from '../manifest'
|
|
7
|
-
|
|
8
|
-
// Structural snapshot — locks in the manifest's contract with the
|
|
9
|
-
// gen-docs pipeline without inline-snapshotting the full prose
|
|
10
|
-
// (which rots on every wording change). Pairs with the en-masse
|
|
11
|
-
// `gen-docs --check` CI gate and the api-reference region marker.
|
|
12
|
-
|
|
13
|
-
describe('gen-docs — head snapshot', () => {
|
|
14
|
-
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
15
|
-
const line = renderLlmsTxtLine(manifest)
|
|
16
|
-
expect(line.startsWith('- @pyreon/head —')).toBe(true)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('renders a llms-full.txt section with the right header', () => {
|
|
20
|
-
const section = renderLlmsFullSection(manifest)
|
|
21
|
-
expect(section.startsWith('## @pyreon/head —')).toBe(true)
|
|
22
|
-
expect(section).toContain('```typescript')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('renders MCP api-reference entries for every api[] item', () => {
|
|
26
|
-
const record = renderApiReferenceEntries(manifest)
|
|
27
|
-
expect(Object.keys(record).sort()).toEqual([
|
|
28
|
-
'head/HeadProvider',
|
|
29
|
-
'head/createHeadContext',
|
|
30
|
-
'head/renderWithHead',
|
|
31
|
-
'head/useHead',
|
|
32
|
-
])
|
|
33
|
-
})
|
|
34
|
-
})
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { isNativeCompat } from '@pyreon/core'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { HeadProvider } from '../provider'
|
|
4
|
-
|
|
5
|
-
describe('native-compat marker — @pyreon/head', () => {
|
|
6
|
-
it('HeadProvider is marked native', () => {
|
|
7
|
-
expect(isNativeCompat(HeadProvider)).toBe(true)
|
|
8
|
-
})
|
|
9
|
-
})
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import type { ComponentFn } from '@pyreon/core'
|
|
2
|
-
import { h } from '@pyreon/core'
|
|
3
|
-
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { createHeadContext, HeadProvider, useHead } from '../index'
|
|
6
|
-
import { renderWithHead } from '../ssr'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* `HeadProvider` resolves its HeadContext as `props.context ?? outer ?? fresh`.
|
|
10
|
-
* That inheritance step is load-bearing for the documented composition
|
|
11
|
-
* `renderWithHead(h(HeadProvider, null, h(App)))` AND for the
|
|
12
|
-
* `@pyreon/zero` SSR/SSG pipeline (whose `createApp` mounts
|
|
13
|
-
* `h(HeadProvider, null, …)` unconditionally with no `context` prop).
|
|
14
|
-
*
|
|
15
|
-
* Pre-fix `HeadProvider` ALWAYS auto-created a fresh ctx and `provide()`d
|
|
16
|
-
* it — silently SHADOWING the ctx that `renderWithHead` had pushed onto
|
|
17
|
-
* the per-request context stack. Every `useHead({...})` call in the
|
|
18
|
-
* subtree wrote tags to the inner ctx (HeadProvider's), but
|
|
19
|
-
* `renderWithHead` resolved the outer ctx (its own, still empty) and
|
|
20
|
-
* produced an empty `<head>` string. Static SSG / SSR output shipped
|
|
21
|
-
* with NO `<title>` / `<meta>` / JSON-LD / OG tags — social scrapers and
|
|
22
|
-
* non-JS crawlers saw nothing. Fixed by adding `useContext(HeadContext)`
|
|
23
|
-
* to the resolution chain so an outer ctx is inherited transparently.
|
|
24
|
-
*/
|
|
25
|
-
describe('HeadProvider — inherits an outer HeadContext (composability contract)', () => {
|
|
26
|
-
it('REGRESSION: `renderWithHead(h(HeadProvider, null, h(App)))` carries useHead tags into <head>', async () => {
|
|
27
|
-
// This is the EXACT shape `@pyreon/zero`'s `createApp` mounts:
|
|
28
|
-
// h(App, null) → h(HeadProvider, null, h(RouterProvider, …, h(RouterView, null)))
|
|
29
|
-
// — i.e. the inner `HeadProvider` has no `context` prop. Pre-fix this
|
|
30
|
-
// produced an empty `head` string; the rendered HTML was perfectly fine.
|
|
31
|
-
const App: ComponentFn = () => {
|
|
32
|
-
useHead({
|
|
33
|
-
title: 'Page Title',
|
|
34
|
-
meta: [{ name: 'description', content: 'page desc' }],
|
|
35
|
-
})
|
|
36
|
-
return h('div', null, 'app body')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const wrapped = h(HeadProvider as ComponentFn, null, h(App, null))
|
|
40
|
-
const { html, head } = await renderWithHead(wrapped)
|
|
41
|
-
|
|
42
|
-
expect(html).toContain('app body')
|
|
43
|
-
expect(head).toContain('<title>Page Title</title>')
|
|
44
|
-
expect(head).toContain('name="description"')
|
|
45
|
-
expect(head).toContain('content="page desc"')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('direct `h(App)` (no inner HeadProvider) still works — baseline parity', async () => {
|
|
49
|
-
const App: ComponentFn = () => {
|
|
50
|
-
useHead({ title: 'Baseline' })
|
|
51
|
-
return h('div', null)
|
|
52
|
-
}
|
|
53
|
-
const { head } = await renderWithHead(h(App, null))
|
|
54
|
-
expect(head).toContain('<title>Baseline</title>')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('explicit `context` prop on the inner HeadProvider still wins (opt-out for isolation)', async () => {
|
|
58
|
-
// Apps that genuinely want an isolated head registry (iframe / micro-
|
|
59
|
-
// frontend) can pass their own ctx; the explicit prop overrides
|
|
60
|
-
// inheritance. The outer ctx that `renderWithHead` resolves remains
|
|
61
|
-
// empty in this case BY DESIGN — verifying the opt-out works.
|
|
62
|
-
const isolatedCtx = createHeadContext()
|
|
63
|
-
const App: ComponentFn = () => {
|
|
64
|
-
useHead({ title: 'Isolated' })
|
|
65
|
-
return h('div', null)
|
|
66
|
-
}
|
|
67
|
-
const wrapped = h(
|
|
68
|
-
HeadProvider as ComponentFn,
|
|
69
|
-
{ context: isolatedCtx },
|
|
70
|
-
h(App, null),
|
|
71
|
-
)
|
|
72
|
-
const { head } = await renderWithHead(wrapped)
|
|
73
|
-
// Tags landed in the isolated ctx, NOT in renderWithHead's outer ctx
|
|
74
|
-
expect(head).toBe('')
|
|
75
|
-
// Confirm the tags really did go into the isolated ctx
|
|
76
|
-
const isolatedTags = isolatedCtx.resolve()
|
|
77
|
-
expect(isolatedTags.find((t) => t.tag === 'title')?.children).toBe('Isolated')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('nested HeadProvider — inner inherits outer ctx, no shadow (registry stays single)', async () => {
|
|
81
|
-
// Two HeadProviders in the same tree should write into ONE registry,
|
|
82
|
-
// not two disjoint ones. Pre-fix the inner one created a fresh ctx,
|
|
83
|
-
// so the outer registry (which renderWithHead resolves) lost the
|
|
84
|
-
// inner subtree's tags. Post-fix the inner inherits the outer ctx
|
|
85
|
-
// and tags from both subtrees land in the same resolved <head>.
|
|
86
|
-
const Inner: ComponentFn = () => {
|
|
87
|
-
useHead({ meta: [{ name: 'inner', content: 'inner-value' }] })
|
|
88
|
-
return h('span', null, 'inner')
|
|
89
|
-
}
|
|
90
|
-
const Outer: ComponentFn = () => {
|
|
91
|
-
useHead({ title: 'Outer Title' })
|
|
92
|
-
return h(
|
|
93
|
-
'div',
|
|
94
|
-
null,
|
|
95
|
-
h(HeadProvider as ComponentFn, null, h(Inner, null)),
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
const { head } = await renderWithHead(h(Outer, null))
|
|
99
|
-
expect(head).toContain('<title>Outer Title</title>')
|
|
100
|
-
expect(head).toContain('name="inner"')
|
|
101
|
-
expect(head).toContain('content="inner-value"')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
describe('CSR root — fresh-ctx fallback preserved (regression guard for the fix)', () => {
|
|
105
|
-
let container: HTMLElement
|
|
106
|
-
beforeEach(() => {
|
|
107
|
-
container = document.createElement('div')
|
|
108
|
-
document.body.appendChild(container)
|
|
109
|
-
for (const el of document.head.querySelectorAll('[data-pyreon-head]'))
|
|
110
|
-
el.remove()
|
|
111
|
-
document.title = ''
|
|
112
|
-
})
|
|
113
|
-
afterEach(() => {
|
|
114
|
-
container.remove()
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('mounts at CSR root with NO `context` prop + NO outer provider → auto-creates fresh ctx, useHead works', () => {
|
|
118
|
-
// When neither `props.context` nor an outer `HeadContext` is in
|
|
119
|
-
// scope, HeadProvider must STILL auto-create a fresh ctx so pure
|
|
120
|
-
// CSR roots work. If the fix accidentally regressed this path
|
|
121
|
-
// (e.g. requiring an outer ctx), `useHead` would no-op silently
|
|
122
|
-
// and `document.title` would stay empty.
|
|
123
|
-
const App: ComponentFn = () => {
|
|
124
|
-
useHead({ title: 'CSR Root' })
|
|
125
|
-
return h('div', null)
|
|
126
|
-
}
|
|
127
|
-
mount(h(HeadProvider as ComponentFn, null, h(App, null)), container)
|
|
128
|
-
expect(document.title).toBe('CSR Root')
|
|
129
|
-
})
|
|
130
|
-
})
|
|
131
|
-
})
|
package/src/tests/setup.ts
DELETED
package/src/use-head.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { onMount, onUnmount, useContext } from '@pyreon/core'
|
|
2
|
-
import { effect } from '@pyreon/reactivity'
|
|
3
|
-
import type { HeadEntry, HeadTag, UseHeadInput } from './context'
|
|
4
|
-
import { HeadContext } from './context'
|
|
5
|
-
import { syncDom } from './dom'
|
|
6
|
-
|
|
7
|
-
/** Cast a strict tag interface to the internal props format, stripping undefined values */
|
|
8
|
-
function toProps(obj: Record<string, string | undefined>): Record<string, string> {
|
|
9
|
-
const result: Record<string, string> = {}
|
|
10
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
11
|
-
if (v !== undefined) result[k] = v
|
|
12
|
-
}
|
|
13
|
-
return result
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function buildEntry(o: UseHeadInput): HeadEntry {
|
|
17
|
-
const tags: HeadTag[] = []
|
|
18
|
-
if (o.title != null) tags.push({ tag: 'title', key: 'title', children: o.title })
|
|
19
|
-
o.meta?.forEach((m, i) => {
|
|
20
|
-
tags.push({
|
|
21
|
-
tag: 'meta',
|
|
22
|
-
key: m.name ?? m.property ?? `meta-${i}`,
|
|
23
|
-
props: toProps(m as Record<string, string | undefined>),
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
o.link?.forEach((l, i) => {
|
|
27
|
-
tags.push({
|
|
28
|
-
tag: 'link',
|
|
29
|
-
key: l.href ? `link-${l.rel || ''}-${l.href}` : l.rel ? `link-${l.rel}` : `link-${i}`,
|
|
30
|
-
props: toProps(l as Record<string, string | undefined>),
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
o.script?.forEach((s, i) => {
|
|
34
|
-
const { children, ...rest } = s
|
|
35
|
-
tags.push({
|
|
36
|
-
tag: 'script',
|
|
37
|
-
key: s.src ?? `script-${i}`,
|
|
38
|
-
props: toProps(rest as Record<string, string | undefined>),
|
|
39
|
-
...(children != null ? { children } : {}),
|
|
40
|
-
})
|
|
41
|
-
})
|
|
42
|
-
o.style?.forEach((s, i) => {
|
|
43
|
-
const { children, ...rest } = s
|
|
44
|
-
tags.push({
|
|
45
|
-
tag: 'style',
|
|
46
|
-
key: `style-${i}`,
|
|
47
|
-
props: toProps(rest as Record<string, string | undefined>),
|
|
48
|
-
children,
|
|
49
|
-
})
|
|
50
|
-
})
|
|
51
|
-
o.noscript?.forEach((ns, i) => {
|
|
52
|
-
tags.push({ tag: 'noscript', key: `noscript-${i}`, children: ns.children })
|
|
53
|
-
})
|
|
54
|
-
if (o.jsonLd) {
|
|
55
|
-
tags.push({
|
|
56
|
-
tag: 'script',
|
|
57
|
-
key: 'jsonld',
|
|
58
|
-
props: { type: 'application/ld+json' },
|
|
59
|
-
children: JSON.stringify(o.jsonLd),
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
if (o.speculationRules) {
|
|
63
|
-
tags.push({
|
|
64
|
-
tag: 'script',
|
|
65
|
-
key: 'speculationrules',
|
|
66
|
-
props: { type: 'speculationrules' },
|
|
67
|
-
children: JSON.stringify(o.speculationRules),
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
if (o.base)
|
|
71
|
-
tags.push({
|
|
72
|
-
tag: 'base',
|
|
73
|
-
key: 'base',
|
|
74
|
-
props: toProps(o.base as Record<string, string | undefined>),
|
|
75
|
-
})
|
|
76
|
-
return {
|
|
77
|
-
tags,
|
|
78
|
-
titleTemplate: o.titleTemplate,
|
|
79
|
-
htmlAttrs: o.htmlAttrs,
|
|
80
|
-
bodyAttrs: o.bodyAttrs,
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Register head tags (title, meta, link, script, style, noscript, base, jsonLd)
|
|
86
|
-
* for the current component.
|
|
87
|
-
*
|
|
88
|
-
* Accepts a static object or a reactive getter:
|
|
89
|
-
* useHead({ title: "My Page", meta: [{ name: "description", content: "..." }] })
|
|
90
|
-
* useHead(() => ({ title: `${count()} items` })) // updates when signal changes
|
|
91
|
-
*
|
|
92
|
-
* Tags are deduplicated by key — innermost component wins.
|
|
93
|
-
* Requires a <HeadProvider> (CSR) or renderWithHead() (SSR) ancestor.
|
|
94
|
-
*/
|
|
95
|
-
export function useHead(input: UseHeadInput | (() => UseHeadInput)): void {
|
|
96
|
-
const ctx = useContext(HeadContext)
|
|
97
|
-
if (!ctx) return // no HeadProvider — silently no-op
|
|
98
|
-
|
|
99
|
-
const id = Symbol()
|
|
100
|
-
|
|
101
|
-
if (typeof input === 'function') {
|
|
102
|
-
if (typeof document !== 'undefined') {
|
|
103
|
-
// CSR: reactive — re-register whenever signals change
|
|
104
|
-
effect(() => {
|
|
105
|
-
ctx.add(id, buildEntry(input()))
|
|
106
|
-
syncDom(ctx)
|
|
107
|
-
})
|
|
108
|
-
} else {
|
|
109
|
-
// SSR: evaluate once synchronously (no effects on server)
|
|
110
|
-
ctx.add(id, buildEntry(input()))
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
ctx.add(id, buildEntry(input))
|
|
114
|
-
onMount(() => {
|
|
115
|
-
syncDom(ctx)
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
onUnmount(() => {
|
|
120
|
-
ctx.remove(id)
|
|
121
|
-
syncDom(ctx)
|
|
122
|
-
})
|
|
123
|
-
}
|