@pyreon/head 0.12.12 → 0.12.14
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 +9 -6
- package/src/tests/head.browser.test.tsx +194 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/head",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
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": {
|
|
@@ -52,21 +52,24 @@
|
|
|
52
52
|
"build": "vl_rolldown_build",
|
|
53
53
|
"dev": "vl_rolldown_build-watch",
|
|
54
54
|
"test": "vitest run",
|
|
55
|
+
"test:browser": "vitest run --config ./vitest.browser.config.ts",
|
|
55
56
|
"typecheck": "tsc --noEmit",
|
|
56
57
|
"lint": "oxlint .",
|
|
57
58
|
"prepublishOnly": "bun run build"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
|
-
"@pyreon/core": "^0.12.
|
|
61
|
-
"@pyreon/reactivity": "^0.12.
|
|
61
|
+
"@pyreon/core": "^0.12.14",
|
|
62
|
+
"@pyreon/reactivity": "^0.12.14"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
65
|
-
"@pyreon/runtime-dom": "^0.12.
|
|
66
|
-
"@pyreon/runtime-server": "^0.12.
|
|
66
|
+
"@pyreon/runtime-dom": "^0.12.14",
|
|
67
|
+
"@pyreon/runtime-server": "^0.12.14",
|
|
68
|
+
"@pyreon/test-utils": "^0.12.10",
|
|
69
|
+
"@vitest/browser-playwright": "^4.1.4"
|
|
67
70
|
},
|
|
68
71
|
"peerDependencies": {
|
|
69
|
-
"@pyreon/runtime-server": "^0.12.
|
|
72
|
+
"@pyreon/runtime-server": "^0.12.14"
|
|
70
73
|
},
|
|
71
74
|
"peerDependenciesMeta": {
|
|
72
75
|
"@pyreon/runtime-server": {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { HeadProvider } from '../provider'
|
|
6
|
+
import { useHead } from '../use-head'
|
|
7
|
+
|
|
8
|
+
// Real-Chromium smoke suite for @pyreon/head.
|
|
9
|
+
//
|
|
10
|
+
// happy-dom mutates a fake `document.head`, but its serialization,
|
|
11
|
+
// attribute order, and `querySelector` semantics differ subtly from
|
|
12
|
+
// real browsers — and several head consumers care about exactly those
|
|
13
|
+
// things (favicon swap on color scheme, dedup by `name`/`property`,
|
|
14
|
+
// `<script type="application/ld+json">` content). This suite exercises
|
|
15
|
+
// the wiring under a real DOM.
|
|
16
|
+
|
|
17
|
+
const Page = (props: { setup: () => void; children?: unknown }) => {
|
|
18
|
+
props.setup()
|
|
19
|
+
return h('div', { id: 'page' }, 'page mounted')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('head in real browser', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
// Clean up any tags this run added — head mutations are global.
|
|
25
|
+
for (const el of document.head.querySelectorAll('[data-pyreon-head]')) {
|
|
26
|
+
el.remove()
|
|
27
|
+
}
|
|
28
|
+
document.title = ''
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('useHead({ title }) writes document.title', () => {
|
|
32
|
+
const { unmount } = mountInBrowser(
|
|
33
|
+
h(
|
|
34
|
+
HeadProvider,
|
|
35
|
+
null,
|
|
36
|
+
h(Page, { setup: () => useHead({ title: 'Hello Browser' }) }),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
expect(document.title).toBe('Hello Browser')
|
|
40
|
+
unmount()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('useHead({ meta }) inserts a real <meta> element with attribute correctness', () => {
|
|
44
|
+
const { unmount } = mountInBrowser(
|
|
45
|
+
h(
|
|
46
|
+
HeadProvider,
|
|
47
|
+
null,
|
|
48
|
+
h(Page, {
|
|
49
|
+
setup: () =>
|
|
50
|
+
useHead({
|
|
51
|
+
meta: [
|
|
52
|
+
{ name: 'description', content: 'Pyreon framework' },
|
|
53
|
+
{ property: 'og:type', content: 'website' },
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
const desc = document.head.querySelector<HTMLMetaElement>('meta[name="description"]')
|
|
60
|
+
const og = document.head.querySelector<HTMLMetaElement>('meta[property="og:type"]')
|
|
61
|
+
|
|
62
|
+
expect(desc?.getAttribute('content')).toBe('Pyreon framework')
|
|
63
|
+
expect(og?.getAttribute('content')).toBe('website')
|
|
64
|
+
unmount()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reactive useHead getter updates the title when the signal changes', async () => {
|
|
68
|
+
const counter = signal(0)
|
|
69
|
+
const { unmount } = mountInBrowser(
|
|
70
|
+
h(
|
|
71
|
+
HeadProvider,
|
|
72
|
+
null,
|
|
73
|
+
h(Page, { setup: () => useHead(() => ({ title: `Items: ${counter()}` })) }),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
expect(document.title).toBe('Items: 0')
|
|
77
|
+
|
|
78
|
+
counter.set(7)
|
|
79
|
+
await flush()
|
|
80
|
+
expect(document.title).toBe('Items: 7')
|
|
81
|
+
|
|
82
|
+
counter.set(42)
|
|
83
|
+
await flush()
|
|
84
|
+
expect(document.title).toBe('Items: 42')
|
|
85
|
+
unmount()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('removes head tags when the providing component unmounts', () => {
|
|
89
|
+
const { unmount } = mountInBrowser(
|
|
90
|
+
h(
|
|
91
|
+
HeadProvider,
|
|
92
|
+
null,
|
|
93
|
+
h(Page, {
|
|
94
|
+
setup: () => useHead({ link: [{ rel: 'canonical', href: 'https://example.com/x' }] }),
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
expect(document.head.querySelector('link[rel="canonical"]')).not.toBeNull()
|
|
99
|
+
|
|
100
|
+
unmount()
|
|
101
|
+
expect(document.head.querySelector('link[rel="canonical"]')).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('htmlAttrs / bodyAttrs are written to <html> and <body>', () => {
|
|
105
|
+
const { unmount } = mountInBrowser(
|
|
106
|
+
h(
|
|
107
|
+
HeadProvider,
|
|
108
|
+
null,
|
|
109
|
+
h(Page, {
|
|
110
|
+
setup: () =>
|
|
111
|
+
useHead({
|
|
112
|
+
htmlAttrs: { lang: 'en', dir: 'ltr' },
|
|
113
|
+
bodyAttrs: { class: 'theme-dark' },
|
|
114
|
+
}),
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
expect(document.documentElement.getAttribute('lang')).toBe('en')
|
|
119
|
+
expect(document.documentElement.getAttribute('dir')).toBe('ltr')
|
|
120
|
+
expect(document.body.getAttribute('class')).toBe('theme-dark')
|
|
121
|
+
unmount()
|
|
122
|
+
// After unmount the helpers remove the contributed attrs.
|
|
123
|
+
expect(document.documentElement.getAttribute('lang')).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('titleTemplate wraps the resolved title', () => {
|
|
127
|
+
const { unmount } = mountInBrowser(
|
|
128
|
+
h(
|
|
129
|
+
HeadProvider,
|
|
130
|
+
null,
|
|
131
|
+
h(Page, {
|
|
132
|
+
setup: () => useHead({ title: 'Dashboard', titleTemplate: '%s | MyApp' }),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
expect(document.title).toBe('Dashboard | MyApp')
|
|
137
|
+
unmount()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('jsonLd convenience emits a <script type="application/ld+json"> with stringified content', () => {
|
|
141
|
+
const ld = { '@context': 'https://schema.org', '@type': 'Organization', name: 'Pyreon' }
|
|
142
|
+
const { unmount } = mountInBrowser(
|
|
143
|
+
h(
|
|
144
|
+
HeadProvider,
|
|
145
|
+
null,
|
|
146
|
+
h(Page, { setup: () => useHead({ jsonLd: ld }) }),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
const script = document.head.querySelector<HTMLScriptElement>(
|
|
150
|
+
'script[type="application/ld+json"]',
|
|
151
|
+
)
|
|
152
|
+
expect(script).not.toBeNull()
|
|
153
|
+
expect(JSON.parse(script!.textContent ?? '{}')).toEqual(ld)
|
|
154
|
+
unmount()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('innermost useHead wins when multiple components contribute the same key', async () => {
|
|
158
|
+
const Inner = () => {
|
|
159
|
+
useHead({ title: 'Inner Wins' })
|
|
160
|
+
return h('div', { id: 'inner' }, 'inner')
|
|
161
|
+
}
|
|
162
|
+
const Outer = () => {
|
|
163
|
+
useHead({ title: 'Outer Loses' })
|
|
164
|
+
return h('div', { id: 'outer' }, h(Inner, {}))
|
|
165
|
+
}
|
|
166
|
+
const { unmount } = mountInBrowser(h(HeadProvider, null, h(Outer, {})))
|
|
167
|
+
await flush()
|
|
168
|
+
// Innermost component's title takes precedence.
|
|
169
|
+
expect(document.title).toBe('Inner Wins')
|
|
170
|
+
unmount()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('script tags inserted with src + async + defer attributes', () => {
|
|
174
|
+
const { unmount } = mountInBrowser(
|
|
175
|
+
h(
|
|
176
|
+
HeadProvider,
|
|
177
|
+
null,
|
|
178
|
+
h(Page, {
|
|
179
|
+
setup: () =>
|
|
180
|
+
useHead({
|
|
181
|
+
script: [{ src: 'https://example.com/analytics.js', async: '', defer: '' }],
|
|
182
|
+
}),
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
const s = document.head.querySelector<HTMLScriptElement>(
|
|
187
|
+
'script[src="https://example.com/analytics.js"]',
|
|
188
|
+
)
|
|
189
|
+
expect(s).not.toBeNull()
|
|
190
|
+
expect(s?.async).toBe(true)
|
|
191
|
+
expect(s?.defer).toBe(true)
|
|
192
|
+
unmount()
|
|
193
|
+
})
|
|
194
|
+
})
|