@pyreon/head 0.11.5 → 0.11.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/README.md +6 -8
- package/lib/index.js.map +1 -1
- package/lib/provider.js.map +1 -1
- package/lib/ssr.js.map +1 -1
- package/lib/types/index.d.ts +4 -4
- package/lib/types/provider.d.ts +1 -1
- package/lib/types/use-head.d.ts +3 -3
- package/lib/use-head.js.map +1 -1
- package/package.json +18 -18
- package/src/context.ts +5 -5
- package/src/dom.ts +7 -7
- package/src/index.ts +5 -5
- package/src/provider.ts +5 -5
- package/src/ssr.ts +15 -15
- package/src/tests/head.test.ts +447 -445
- package/src/tests/setup.ts +1 -1
- package/src/use-head.ts +19 -19
package/src/tests/head.test.ts
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { h } from
|
|
2
|
-
import { signal } from
|
|
3
|
-
import { mount } from
|
|
4
|
-
import type { HeadContextValue } from
|
|
5
|
-
import { createHeadContext, HeadProvider, useHead } from
|
|
6
|
-
import { renderWithHead } from
|
|
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
|
+
import { renderWithHead } from '../ssr'
|
|
7
7
|
|
|
8
8
|
// ─── SSR tests ────────────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
|
-
describe(
|
|
11
|
-
test(
|
|
10
|
+
describe('renderWithHead — SSR', () => {
|
|
11
|
+
test('extracts <title> from useHead', async () => {
|
|
12
12
|
function Page() {
|
|
13
|
-
useHead({ title:
|
|
14
|
-
return h(
|
|
13
|
+
useHead({ title: 'Hello Pyreon' })
|
|
14
|
+
return h('main', null, 'content')
|
|
15
15
|
}
|
|
16
16
|
const { html, head } = await renderWithHead(h(Page, null))
|
|
17
|
-
expect(html).toContain(
|
|
18
|
-
expect(head).toContain(
|
|
17
|
+
expect(html).toContain('<main>')
|
|
18
|
+
expect(head).toContain('<title>Hello Pyreon</title>')
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
test(
|
|
21
|
+
test('extracts <meta> tags from useHead', async () => {
|
|
22
22
|
function Page() {
|
|
23
23
|
useHead({
|
|
24
24
|
meta: [
|
|
25
|
-
{ name:
|
|
26
|
-
{ property:
|
|
25
|
+
{ name: 'description', content: 'A great page' },
|
|
26
|
+
{ property: 'og:title', content: 'Hello' },
|
|
27
27
|
],
|
|
28
28
|
})
|
|
29
|
-
return h(
|
|
29
|
+
return h('div', null)
|
|
30
30
|
}
|
|
31
31
|
const { head } = await renderWithHead(h(Page, null))
|
|
32
32
|
expect(head).toContain('name="description"')
|
|
@@ -34,74 +34,74 @@ describe("renderWithHead — SSR", () => {
|
|
|
34
34
|
expect(head).toContain('property="og:title"')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
test(
|
|
37
|
+
test('extracts <link> tags from useHead', async () => {
|
|
38
38
|
function Page() {
|
|
39
|
-
useHead({ link: [{ rel:
|
|
40
|
-
return h(
|
|
39
|
+
useHead({ link: [{ rel: 'canonical', href: 'https://example.com/page' }] })
|
|
40
|
+
return h('div', null)
|
|
41
41
|
}
|
|
42
42
|
const { head } = await renderWithHead(h(Page, null))
|
|
43
43
|
expect(head).toContain('rel="canonical"')
|
|
44
44
|
expect(head).toContain('href="https://example.com/page"')
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test(
|
|
47
|
+
test('deduplication: innermost title wins', async () => {
|
|
48
48
|
function Inner() {
|
|
49
|
-
useHead({ title:
|
|
50
|
-
return h(
|
|
49
|
+
useHead({ title: 'Inner Title' })
|
|
50
|
+
return h('span', null)
|
|
51
51
|
}
|
|
52
52
|
function Outer() {
|
|
53
|
-
useHead({ title:
|
|
54
|
-
return h(
|
|
53
|
+
useHead({ title: 'Outer Title' })
|
|
54
|
+
return h('div', null, h(Inner, null))
|
|
55
55
|
}
|
|
56
56
|
const { head } = await renderWithHead(h(Outer, null))
|
|
57
57
|
// Only one <title> — Inner wins (last-added wins per key)
|
|
58
58
|
const titleMatches = head.match(/<title>/g)
|
|
59
59
|
expect(titleMatches).toHaveLength(1)
|
|
60
|
-
expect(head).toContain(
|
|
60
|
+
expect(head).toContain('<title>Inner Title</title>')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test(
|
|
63
|
+
test('escapes HTML entities in title', async () => {
|
|
64
64
|
function Page() {
|
|
65
|
-
useHead({ title:
|
|
66
|
-
return h(
|
|
65
|
+
useHead({ title: 'A & B <script>' })
|
|
66
|
+
return h('div', null)
|
|
67
67
|
}
|
|
68
68
|
const { head } = await renderWithHead(h(Page, null))
|
|
69
|
-
expect(head).toContain(
|
|
70
|
-
expect(head).not.toContain(
|
|
69
|
+
expect(head).toContain('A & B <script>')
|
|
70
|
+
expect(head).not.toContain('<script>')
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
test(
|
|
73
|
+
test('works with async component', async () => {
|
|
74
74
|
async function AsyncPage() {
|
|
75
75
|
await new Promise((r) => setTimeout(r, 1))
|
|
76
|
-
useHead({ title:
|
|
77
|
-
return h(
|
|
76
|
+
useHead({ title: 'Async Page' })
|
|
77
|
+
return h('div', null)
|
|
78
78
|
}
|
|
79
79
|
const { head } = await renderWithHead(h(AsyncPage as never, null))
|
|
80
|
-
expect(head).toContain(
|
|
80
|
+
expect(head).toContain('<title>Async Page</title>')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
test(
|
|
83
|
+
test('renders <style> tags', async () => {
|
|
84
84
|
function Page() {
|
|
85
|
-
useHead({ style: [{ children:
|
|
86
|
-
return h(
|
|
85
|
+
useHead({ style: [{ children: 'body { color: red }' }] })
|
|
86
|
+
return h('div', null)
|
|
87
87
|
}
|
|
88
88
|
const { head } = await renderWithHead(h(Page, null))
|
|
89
|
-
expect(head).toContain(
|
|
89
|
+
expect(head).toContain('<style>body { color: red }</style>')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test(
|
|
92
|
+
test('renders <noscript> tags', async () => {
|
|
93
93
|
function Page() {
|
|
94
|
-
useHead({ noscript: [{ children:
|
|
95
|
-
return h(
|
|
94
|
+
useHead({ noscript: [{ children: '<p>Please enable JavaScript</p>' }] })
|
|
95
|
+
return h('div', null)
|
|
96
96
|
}
|
|
97
97
|
const { head } = await renderWithHead(h(Page, null))
|
|
98
|
-
expect(head).toContain(
|
|
98
|
+
expect(head).toContain('<noscript><p>Please enable JavaScript</p></noscript>')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
test(
|
|
101
|
+
test('renders JSON-LD script tag', async () => {
|
|
102
102
|
function Page() {
|
|
103
|
-
useHead({ jsonLd: {
|
|
104
|
-
return h(
|
|
103
|
+
useHead({ jsonLd: { '@type': 'WebPage', name: 'Test' } })
|
|
104
|
+
return h('div', null)
|
|
105
105
|
}
|
|
106
106
|
const { head } = await renderWithHead(h(Page, null))
|
|
107
107
|
expect(head).toContain('type="application/ld+json"')
|
|
@@ -109,53 +109,53 @@ describe("renderWithHead — SSR", () => {
|
|
|
109
109
|
expect(head).toContain('"name":"Test"')
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
-
test(
|
|
112
|
+
test('script content is not HTML-escaped', async () => {
|
|
113
113
|
function Page() {
|
|
114
|
-
useHead({ script: [{ children:
|
|
115
|
-
return h(
|
|
114
|
+
useHead({ script: [{ children: 'var x = 1 < 2 && 3 > 1' }] })
|
|
115
|
+
return h('div', null)
|
|
116
116
|
}
|
|
117
117
|
const { head } = await renderWithHead(h(Page, null))
|
|
118
|
-
expect(head).toContain(
|
|
119
|
-
expect(head).not.toContain(
|
|
118
|
+
expect(head).toContain('var x = 1 < 2 && 3 > 1')
|
|
119
|
+
expect(head).not.toContain('<')
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
test(
|
|
122
|
+
test('titleTemplate with %s placeholder', async () => {
|
|
123
123
|
function Layout() {
|
|
124
|
-
useHead({ titleTemplate:
|
|
125
|
-
return h(
|
|
124
|
+
useHead({ titleTemplate: '%s | My App' })
|
|
125
|
+
return h('div', null, h(Page, null))
|
|
126
126
|
}
|
|
127
127
|
function Page() {
|
|
128
|
-
useHead({ title:
|
|
129
|
-
return h(
|
|
128
|
+
useHead({ title: 'About' })
|
|
129
|
+
return h('span', null)
|
|
130
130
|
}
|
|
131
131
|
const { head } = await renderWithHead(h(Layout, null))
|
|
132
|
-
expect(head).toContain(
|
|
132
|
+
expect(head).toContain('<title>About | My App</title>')
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
test(
|
|
135
|
+
test('titleTemplate with function', async () => {
|
|
136
136
|
function Layout() {
|
|
137
|
-
useHead({ titleTemplate: (t: string) => (t ? `${t} — Site` :
|
|
138
|
-
return h(
|
|
137
|
+
useHead({ titleTemplate: (t: string) => (t ? `${t} — Site` : 'Site') })
|
|
138
|
+
return h('div', null, h(Page, null))
|
|
139
139
|
}
|
|
140
140
|
function Page() {
|
|
141
|
-
useHead({ title:
|
|
142
|
-
return h(
|
|
141
|
+
useHead({ title: 'Home' })
|
|
142
|
+
return h('span', null)
|
|
143
143
|
}
|
|
144
144
|
const { head } = await renderWithHead(h(Layout, null))
|
|
145
|
-
expect(head).toContain(
|
|
145
|
+
expect(head).toContain('<title>Home — Site</title>')
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
-
test(
|
|
148
|
+
test('returns htmlAttrs and bodyAttrs', async () => {
|
|
149
149
|
function Page() {
|
|
150
|
-
useHead({ htmlAttrs: { lang:
|
|
151
|
-
return h(
|
|
150
|
+
useHead({ htmlAttrs: { lang: 'en', dir: 'ltr' }, bodyAttrs: { class: 'dark' } })
|
|
151
|
+
return h('div', null)
|
|
152
152
|
}
|
|
153
153
|
const result = await renderWithHead(h(Page, null))
|
|
154
|
-
expect(result.htmlAttrs).toEqual({ lang:
|
|
155
|
-
expect(result.bodyAttrs).toEqual({ class:
|
|
154
|
+
expect(result.htmlAttrs).toEqual({ lang: 'en', dir: 'ltr' })
|
|
155
|
+
expect(result.bodyAttrs).toEqual({ class: 'dark' })
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
-
test(
|
|
158
|
+
test('ssr.ts: serializeTag for non-void, non-raw tag with children (line 76)', async () => {
|
|
159
159
|
// A <noscript> is raw, but let's use a tag that goes through esc() path
|
|
160
160
|
// Actually noscript IS raw. We need a non-void, non-raw tag with children.
|
|
161
161
|
// The only non-void non-raw tags possible: title (handled separately).
|
|
@@ -176,87 +176,87 @@ describe("renderWithHead — SSR", () => {
|
|
|
176
176
|
// Line 60: tag.children ?? "" when children is undefined
|
|
177
177
|
function _Page() {
|
|
178
178
|
// title with undefined children — manually add to context
|
|
179
|
-
return h(
|
|
179
|
+
return h('div', null)
|
|
180
180
|
}
|
|
181
181
|
// Test title with no children set (tag.children is undefined → fallback to "")
|
|
182
182
|
const ctx2 = createHeadContext()
|
|
183
|
-
ctx2.add(Symbol(), { tags: [{ tag:
|
|
183
|
+
ctx2.add(Symbol(), { tags: [{ tag: 'title', key: 'title' }] }) // no children prop
|
|
184
184
|
const tags = ctx2.resolve()
|
|
185
185
|
expect(tags[0]?.children).toBeUndefined()
|
|
186
186
|
// Now test through renderWithHead — the title tag should render with empty content
|
|
187
187
|
function PageNoTitle() {
|
|
188
|
-
useHead({ title:
|
|
189
|
-
return h(
|
|
188
|
+
useHead({ title: '' })
|
|
189
|
+
return h('div', null)
|
|
190
190
|
}
|
|
191
191
|
const result = await renderWithHead(h(PageNoTitle, null))
|
|
192
|
-
expect(result.head).toContain(
|
|
192
|
+
expect(result.head).toContain('<title></title>')
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
-
test(
|
|
195
|
+
test('ssr.ts: serializeTag renders noscript with closing-tag escaping (line 76)', async () => {
|
|
196
196
|
function Page() {
|
|
197
197
|
useHead({
|
|
198
|
-
noscript: [{ children:
|
|
198
|
+
noscript: [{ children: 'test </noscript><script>alert(1)</script>' }],
|
|
199
199
|
})
|
|
200
|
-
return h(
|
|
200
|
+
return h('div', null)
|
|
201
201
|
}
|
|
202
202
|
const { head } = await renderWithHead(h(Page, null))
|
|
203
203
|
// The raw path should escape </noscript> to <\/noscript>
|
|
204
|
-
expect(head).toContain(
|
|
204
|
+
expect(head).toContain('<\\/noscript>')
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
-
test(
|
|
207
|
+
test('ssr.ts: serializeTag for title with undefined children (line 60)', async () => {
|
|
208
208
|
// Directly test by adding a title tag with no children to context
|
|
209
209
|
const ctx2 = createHeadContext()
|
|
210
|
-
ctx2.add(Symbol(), { tags: [{ tag:
|
|
210
|
+
ctx2.add(Symbol(), { tags: [{ tag: 'title', key: 'title' }] })
|
|
211
211
|
// Use renderWithHead with a component that adds title tag without children
|
|
212
212
|
function _Page() {
|
|
213
|
-
return h(
|
|
213
|
+
return h('div', null)
|
|
214
214
|
}
|
|
215
215
|
// We need to test serializeTag with title where children is undefined
|
|
216
216
|
// Since we can't call serializeTag directly, let's use renderWithHead
|
|
217
217
|
// with a titleTemplate and no title to exercise both template branches
|
|
218
218
|
function PageWithTemplate() {
|
|
219
|
-
useHead({ titleTemplate:
|
|
220
|
-
return h(
|
|
219
|
+
useHead({ titleTemplate: '%s | Site' })
|
|
220
|
+
return h('div', null, h(Inner, null))
|
|
221
221
|
}
|
|
222
222
|
function Inner() {
|
|
223
|
-
useHead({ title:
|
|
224
|
-
return h(
|
|
223
|
+
useHead({ title: '' }) // empty string title with template
|
|
224
|
+
return h('span', null)
|
|
225
225
|
}
|
|
226
226
|
const { head } = await renderWithHead(h(PageWithTemplate, null))
|
|
227
|
-
expect(head).toContain(
|
|
227
|
+
expect(head).toContain('<title> | Site</title>')
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
-
test(
|
|
230
|
+
test('ssr.ts: script tag with closing tag escaping (line 76)', async () => {
|
|
231
231
|
function Page() {
|
|
232
232
|
useHead({
|
|
233
233
|
script: [{ children: "var x = '</script><img onerror=alert(1)>'" }],
|
|
234
234
|
})
|
|
235
|
-
return h(
|
|
235
|
+
return h('div', null)
|
|
236
236
|
}
|
|
237
237
|
const { head } = await renderWithHead(h(Page, null))
|
|
238
|
-
expect(head).toContain(
|
|
238
|
+
expect(head).toContain('<\\/script>')
|
|
239
239
|
})
|
|
240
240
|
|
|
241
|
-
test(
|
|
241
|
+
test('use-head.ts: reactive function input on SSR (line 88)', async () => {
|
|
242
242
|
// The SSR path for function input evaluates once synchronously
|
|
243
243
|
function Page() {
|
|
244
|
-
useHead(() => ({ title:
|
|
245
|
-
return h(
|
|
244
|
+
useHead(() => ({ title: 'SSR Reactive' }))
|
|
245
|
+
return h('div', null)
|
|
246
246
|
}
|
|
247
247
|
const { head } = await renderWithHead(h(Page, null))
|
|
248
|
-
expect(head).toContain(
|
|
248
|
+
expect(head).toContain('<title>SSR Reactive</title>')
|
|
249
249
|
})
|
|
250
250
|
|
|
251
|
-
test(
|
|
251
|
+
test('multiple link tags with same rel but different href are kept', async () => {
|
|
252
252
|
function Page() {
|
|
253
253
|
useHead({
|
|
254
254
|
link: [
|
|
255
|
-
{ rel:
|
|
256
|
-
{ rel:
|
|
255
|
+
{ rel: 'stylesheet', href: '/a.css' },
|
|
256
|
+
{ rel: 'stylesheet', href: '/b.css' },
|
|
257
257
|
],
|
|
258
258
|
})
|
|
259
|
-
return h(
|
|
259
|
+
return h('div', null)
|
|
260
260
|
}
|
|
261
261
|
const { head } = await renderWithHead(h(Page, null))
|
|
262
262
|
expect(head).toContain('href="/a.css"')
|
|
@@ -266,43 +266,43 @@ describe("renderWithHead — SSR", () => {
|
|
|
266
266
|
|
|
267
267
|
// ─── CSR tests ────────────────────────────────────────────────────────────────
|
|
268
268
|
|
|
269
|
-
describe(
|
|
269
|
+
describe('useHead — CSR', () => {
|
|
270
270
|
let container: HTMLElement
|
|
271
271
|
let ctx: HeadContextValue
|
|
272
272
|
|
|
273
273
|
beforeEach(() => {
|
|
274
|
-
container = document.createElement(
|
|
274
|
+
container = document.createElement('div')
|
|
275
275
|
document.body.appendChild(container)
|
|
276
276
|
ctx = createHeadContext()
|
|
277
277
|
// Clean up any pyreon-injected head tags from prior tests
|
|
278
|
-
for (const el of document.head.querySelectorAll(
|
|
279
|
-
document.title =
|
|
278
|
+
for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
|
|
279
|
+
document.title = ''
|
|
280
280
|
})
|
|
281
281
|
|
|
282
|
-
test(
|
|
282
|
+
test('syncs document.title on mount', () => {
|
|
283
283
|
function Page() {
|
|
284
|
-
useHead({ title:
|
|
285
|
-
return h(
|
|
284
|
+
useHead({ title: 'CSR Title' })
|
|
285
|
+
return h('div', null)
|
|
286
286
|
}
|
|
287
287
|
// When using h() directly (not JSX), component children must be in props.children
|
|
288
288
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
289
|
-
expect(document.title).toBe(
|
|
289
|
+
expect(document.title).toBe('CSR Title')
|
|
290
290
|
})
|
|
291
291
|
|
|
292
|
-
test(
|
|
292
|
+
test('syncs <meta> tags on mount', () => {
|
|
293
293
|
function Page() {
|
|
294
|
-
useHead({ meta: [{ name:
|
|
295
|
-
return h(
|
|
294
|
+
useHead({ meta: [{ name: 'description', content: 'CSR desc' }] })
|
|
295
|
+
return h('div', null)
|
|
296
296
|
}
|
|
297
297
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
298
298
|
const meta = document.head.querySelector('meta[name="description"]')
|
|
299
|
-
expect(meta?.getAttribute(
|
|
299
|
+
expect(meta?.getAttribute('content')).toBe('CSR desc')
|
|
300
300
|
})
|
|
301
301
|
|
|
302
|
-
test(
|
|
302
|
+
test('removes meta tags on unmount', () => {
|
|
303
303
|
function Page() {
|
|
304
|
-
useHead({ meta: [{ name:
|
|
305
|
-
return h(
|
|
304
|
+
useHead({ meta: [{ name: 'keywords', content: 'pyreon' }] })
|
|
305
|
+
return h('div', null)
|
|
306
306
|
}
|
|
307
307
|
const cleanup = mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
308
308
|
expect(document.head.querySelector('meta[name="keywords"]')).not.toBeNull()
|
|
@@ -310,33 +310,33 @@ describe("useHead — CSR", () => {
|
|
|
310
310
|
expect(document.head.querySelector('meta[name="keywords"]')).toBeNull()
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
-
test(
|
|
314
|
-
const title = signal(
|
|
313
|
+
test('reactive useHead updates title when signal changes', () => {
|
|
314
|
+
const title = signal('Initial')
|
|
315
315
|
function Page() {
|
|
316
316
|
useHead(() => ({ title: title() }))
|
|
317
|
-
return h(
|
|
317
|
+
return h('div', null)
|
|
318
318
|
}
|
|
319
319
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
320
|
-
expect(document.title).toBe(
|
|
321
|
-
title.set(
|
|
322
|
-
expect(document.title).toBe(
|
|
320
|
+
expect(document.title).toBe('Initial')
|
|
321
|
+
title.set('Updated')
|
|
322
|
+
expect(document.title).toBe('Updated')
|
|
323
323
|
})
|
|
324
324
|
|
|
325
|
-
test(
|
|
325
|
+
test('syncs <style> tags on mount', () => {
|
|
326
326
|
function Page() {
|
|
327
|
-
useHead({ style: [{ children:
|
|
328
|
-
return h(
|
|
327
|
+
useHead({ style: [{ children: 'body { margin: 0 }' }] })
|
|
328
|
+
return h('div', null)
|
|
329
329
|
}
|
|
330
330
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
331
|
-
const style = document.head.querySelector(
|
|
331
|
+
const style = document.head.querySelector('style[data-pyreon-head]')
|
|
332
332
|
expect(style).not.toBeNull()
|
|
333
|
-
expect(style?.textContent).toBe(
|
|
333
|
+
expect(style?.textContent).toBe('body { margin: 0 }')
|
|
334
334
|
})
|
|
335
335
|
|
|
336
|
-
test(
|
|
336
|
+
test('syncs JSON-LD script on mount', () => {
|
|
337
337
|
function Page() {
|
|
338
|
-
useHead({ jsonLd: {
|
|
339
|
-
return h(
|
|
338
|
+
useHead({ jsonLd: { '@type': 'WebPage', name: 'Test' } })
|
|
339
|
+
return h('div', null)
|
|
340
340
|
}
|
|
341
341
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
342
342
|
const script = document.head.querySelector('script[type="application/ld+json"]')
|
|
@@ -344,75 +344,75 @@ describe("useHead — CSR", () => {
|
|
|
344
344
|
expect(script?.textContent).toContain('"@type":"WebPage"')
|
|
345
345
|
})
|
|
346
346
|
|
|
347
|
-
test(
|
|
347
|
+
test('titleTemplate applies to document.title', () => {
|
|
348
348
|
function Layout() {
|
|
349
|
-
useHead({ titleTemplate:
|
|
350
|
-
return h(
|
|
349
|
+
useHead({ titleTemplate: '%s | My App' })
|
|
350
|
+
return h('div', null, h(Page, null))
|
|
351
351
|
}
|
|
352
352
|
function Page() {
|
|
353
|
-
useHead({ title:
|
|
354
|
-
return h(
|
|
353
|
+
useHead({ title: 'Dashboard' })
|
|
354
|
+
return h('span', null)
|
|
355
355
|
}
|
|
356
356
|
mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
|
|
357
|
-
expect(document.title).toBe(
|
|
357
|
+
expect(document.title).toBe('Dashboard | My App')
|
|
358
358
|
})
|
|
359
359
|
|
|
360
|
-
test(
|
|
360
|
+
test('htmlAttrs sets attributes on <html>', () => {
|
|
361
361
|
function Page() {
|
|
362
|
-
useHead({ htmlAttrs: { lang:
|
|
363
|
-
return h(
|
|
362
|
+
useHead({ htmlAttrs: { lang: 'fr' } })
|
|
363
|
+
return h('div', null)
|
|
364
364
|
}
|
|
365
365
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
366
|
-
expect(document.documentElement.getAttribute(
|
|
366
|
+
expect(document.documentElement.getAttribute('lang')).toBe('fr')
|
|
367
367
|
})
|
|
368
368
|
|
|
369
|
-
test(
|
|
369
|
+
test('bodyAttrs sets attributes on <body>', () => {
|
|
370
370
|
function Page() {
|
|
371
|
-
useHead({ bodyAttrs: { class:
|
|
372
|
-
return h(
|
|
371
|
+
useHead({ bodyAttrs: { class: 'dark-mode' } })
|
|
372
|
+
return h('div', null)
|
|
373
373
|
}
|
|
374
374
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
375
|
-
expect(document.body.getAttribute(
|
|
375
|
+
expect(document.body.getAttribute('class')).toBe('dark-mode')
|
|
376
376
|
})
|
|
377
377
|
|
|
378
|
-
test(
|
|
379
|
-
const desc = signal(
|
|
378
|
+
test('incremental sync updates attributes in place', () => {
|
|
379
|
+
const desc = signal('initial')
|
|
380
380
|
function Page() {
|
|
381
|
-
useHead(() => ({ meta: [{ name:
|
|
382
|
-
return h(
|
|
381
|
+
useHead(() => ({ meta: [{ name: 'description', content: desc() }] }))
|
|
382
|
+
return h('div', null)
|
|
383
383
|
}
|
|
384
384
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
385
385
|
const meta1 = document.head.querySelector('meta[name="description"]')
|
|
386
|
-
expect(meta1?.getAttribute(
|
|
387
|
-
desc.set(
|
|
386
|
+
expect(meta1?.getAttribute('content')).toBe('initial')
|
|
387
|
+
desc.set('updated')
|
|
388
388
|
const meta2 = document.head.querySelector('meta[name="description"]')
|
|
389
389
|
// Same element should be reused (incremental sync)
|
|
390
390
|
expect(meta2).toBe(meta1)
|
|
391
|
-
expect(meta2?.getAttribute(
|
|
391
|
+
expect(meta2?.getAttribute('content')).toBe('updated')
|
|
392
392
|
})
|
|
393
393
|
|
|
394
|
-
test(
|
|
394
|
+
test('titleTemplate function applies to document.title in CSR', () => {
|
|
395
395
|
function Layout() {
|
|
396
|
-
useHead({ titleTemplate: (t: string) => (t ? `${t} - App` :
|
|
397
|
-
return h(
|
|
396
|
+
useHead({ titleTemplate: (t: string) => (t ? `${t} - App` : 'App') })
|
|
397
|
+
return h('div', null, h(Page, null))
|
|
398
398
|
}
|
|
399
399
|
function Page() {
|
|
400
|
-
useHead({ title:
|
|
401
|
-
return h(
|
|
400
|
+
useHead({ title: 'CSR Page' })
|
|
401
|
+
return h('span', null)
|
|
402
402
|
}
|
|
403
403
|
mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
|
|
404
|
-
expect(document.title).toBe(
|
|
404
|
+
expect(document.title).toBe('CSR Page - App')
|
|
405
405
|
})
|
|
406
406
|
|
|
407
|
-
test(
|
|
407
|
+
test('removes stale elements when tags change', () => {
|
|
408
408
|
const show = signal(true)
|
|
409
409
|
function Page() {
|
|
410
410
|
useHead(() => {
|
|
411
411
|
const tags: { name: string; content: string }[] = []
|
|
412
|
-
if (show()) tags.push({ name:
|
|
412
|
+
if (show()) tags.push({ name: 'keywords', content: 'pyreon' })
|
|
413
413
|
return { meta: tags }
|
|
414
414
|
})
|
|
415
|
-
return h(
|
|
415
|
+
return h('div', null)
|
|
416
416
|
}
|
|
417
417
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
418
418
|
expect(document.head.querySelector('meta[name="keywords"]')).not.toBeNull()
|
|
@@ -420,368 +420,368 @@ describe("useHead — CSR", () => {
|
|
|
420
420
|
expect(document.head.querySelector('meta[name="keywords"]')).toBeNull()
|
|
421
421
|
})
|
|
422
422
|
|
|
423
|
-
test(
|
|
424
|
-
const attrs = signal<Record<string, string>>({ name:
|
|
423
|
+
test('patchAttrs removes old attributes no longer in props', () => {
|
|
424
|
+
const attrs = signal<Record<string, string>>({ name: 'test', content: 'value' })
|
|
425
425
|
function Page() {
|
|
426
426
|
useHead(() => ({ meta: [attrs()] }))
|
|
427
|
-
return h(
|
|
427
|
+
return h('div', null)
|
|
428
428
|
}
|
|
429
429
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
430
430
|
const el = document.head.querySelector('meta[name="test"]')
|
|
431
|
-
expect(el?.getAttribute(
|
|
431
|
+
expect(el?.getAttribute('content')).toBe('value')
|
|
432
432
|
// Change attrs to remove 'content'
|
|
433
|
-
attrs.set({ name:
|
|
433
|
+
attrs.set({ name: 'test' })
|
|
434
434
|
const el2 = document.head.querySelector('meta[name="test"]')
|
|
435
|
-
expect(el2?.getAttribute(
|
|
435
|
+
expect(el2?.getAttribute('content')).toBeNull()
|
|
436
436
|
})
|
|
437
437
|
|
|
438
|
-
test(
|
|
438
|
+
test('syncElementAttrs removes previously managed attrs', () => {
|
|
439
439
|
const show = signal(true)
|
|
440
440
|
function Page() {
|
|
441
|
-
useHead(() => (show() ? { htmlAttrs: { lang:
|
|
442
|
-
return h(
|
|
441
|
+
useHead(() => (show() ? { htmlAttrs: { lang: 'en', dir: 'ltr' } } : { htmlAttrs: {} }))
|
|
442
|
+
return h('div', null)
|
|
443
443
|
}
|
|
444
444
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
445
|
-
expect(document.documentElement.getAttribute(
|
|
446
|
-
expect(document.documentElement.getAttribute(
|
|
445
|
+
expect(document.documentElement.getAttribute('lang')).toBe('en')
|
|
446
|
+
expect(document.documentElement.getAttribute('dir')).toBe('ltr')
|
|
447
447
|
show.set(false)
|
|
448
448
|
// Previously managed attrs should be removed
|
|
449
|
-
expect(document.documentElement.getAttribute(
|
|
450
|
-
expect(document.documentElement.getAttribute(
|
|
449
|
+
expect(document.documentElement.getAttribute('lang')).toBeNull()
|
|
450
|
+
expect(document.documentElement.getAttribute('dir')).toBeNull()
|
|
451
451
|
})
|
|
452
452
|
|
|
453
|
-
test(
|
|
453
|
+
test('link tag key deduplication by rel when no href', async () => {
|
|
454
454
|
function Page() {
|
|
455
455
|
useHead({
|
|
456
|
-
link: [{ rel:
|
|
456
|
+
link: [{ rel: 'icon' }],
|
|
457
457
|
})
|
|
458
|
-
return h(
|
|
458
|
+
return h('div', null)
|
|
459
459
|
}
|
|
460
460
|
const { head } = await renderWithHead(h(Page, null))
|
|
461
|
-
expect(head).toContain(
|
|
461
|
+
expect(head).toContain('rel=')
|
|
462
462
|
})
|
|
463
463
|
|
|
464
|
-
test(
|
|
464
|
+
test('link tag key uses index when no href or rel', async () => {
|
|
465
465
|
function Page() {
|
|
466
466
|
useHead({
|
|
467
|
-
link: [{ crossorigin:
|
|
467
|
+
link: [{ crossorigin: 'anonymous' }],
|
|
468
468
|
})
|
|
469
|
-
return h(
|
|
469
|
+
return h('div', null)
|
|
470
470
|
}
|
|
471
471
|
const { head } = await renderWithHead(h(Page, null))
|
|
472
|
-
expect(head).toContain(
|
|
472
|
+
expect(head).toContain('<link')
|
|
473
473
|
})
|
|
474
474
|
|
|
475
|
-
test(
|
|
475
|
+
test('meta tag key uses property when name is absent', async () => {
|
|
476
476
|
function Page() {
|
|
477
477
|
useHead({
|
|
478
|
-
meta: [{ property:
|
|
478
|
+
meta: [{ property: 'og:title', content: 'OG' }],
|
|
479
479
|
})
|
|
480
|
-
return h(
|
|
480
|
+
return h('div', null)
|
|
481
481
|
}
|
|
482
482
|
const { head } = await renderWithHead(h(Page, null))
|
|
483
483
|
expect(head).toContain('property="og:title"')
|
|
484
484
|
})
|
|
485
485
|
|
|
486
|
-
test(
|
|
486
|
+
test('meta tag key falls back to index when no name or property', async () => {
|
|
487
487
|
function Page() {
|
|
488
488
|
useHead({
|
|
489
|
-
meta: [{ charset:
|
|
489
|
+
meta: [{ charset: 'utf-8' }],
|
|
490
490
|
})
|
|
491
|
-
return h(
|
|
491
|
+
return h('div', null)
|
|
492
492
|
}
|
|
493
493
|
const { head } = await renderWithHead(h(Page, null))
|
|
494
494
|
expect(head).toContain('charset="utf-8"')
|
|
495
495
|
})
|
|
496
496
|
|
|
497
|
-
test(
|
|
497
|
+
test('script tag with src uses src as key', async () => {
|
|
498
498
|
function Page() {
|
|
499
499
|
useHead({
|
|
500
|
-
script: [{ src:
|
|
500
|
+
script: [{ src: '/app.js' }],
|
|
501
501
|
})
|
|
502
|
-
return h(
|
|
502
|
+
return h('div', null)
|
|
503
503
|
}
|
|
504
504
|
const { head } = await renderWithHead(h(Page, null))
|
|
505
505
|
expect(head).toContain('src="/app.js"')
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
-
test(
|
|
508
|
+
test('script tag without src uses index as key', async () => {
|
|
509
509
|
function Page() {
|
|
510
510
|
useHead({
|
|
511
511
|
script: [{ children: "console.log('hi')" }],
|
|
512
512
|
})
|
|
513
|
-
return h(
|
|
513
|
+
return h('div', null)
|
|
514
514
|
}
|
|
515
515
|
const { head } = await renderWithHead(h(Page, null))
|
|
516
516
|
expect(head).toContain("console.log('hi')")
|
|
517
517
|
})
|
|
518
518
|
|
|
519
|
-
test(
|
|
519
|
+
test('base tag renders in SSR', async () => {
|
|
520
520
|
function Page() {
|
|
521
|
-
useHead({ base: { href:
|
|
522
|
-
return h(
|
|
521
|
+
useHead({ base: { href: '/' } })
|
|
522
|
+
return h('div', null)
|
|
523
523
|
}
|
|
524
524
|
const { head } = await renderWithHead(h(Page, null))
|
|
525
|
-
expect(head).toContain(
|
|
525
|
+
expect(head).toContain('<base')
|
|
526
526
|
expect(head).toContain('href="/"')
|
|
527
527
|
})
|
|
528
528
|
|
|
529
|
-
test(
|
|
529
|
+
test('noscript raw content escaping in SSR', async () => {
|
|
530
530
|
function Page() {
|
|
531
531
|
useHead({
|
|
532
|
-
noscript: [{ children:
|
|
532
|
+
noscript: [{ children: '<p>Enable JS</p>' }],
|
|
533
533
|
})
|
|
534
|
-
return h(
|
|
534
|
+
return h('div', null)
|
|
535
535
|
}
|
|
536
536
|
const { head } = await renderWithHead(h(Page, null))
|
|
537
537
|
// noscript is a raw tag, content preserved
|
|
538
|
-
expect(head).toContain(
|
|
538
|
+
expect(head).toContain('<p>Enable JS</p>')
|
|
539
539
|
})
|
|
540
540
|
|
|
541
|
-
test(
|
|
541
|
+
test('style content escaping in SSR prevents tag injection', async () => {
|
|
542
542
|
function Page() {
|
|
543
543
|
useHead({
|
|
544
|
-
style: [{ children:
|
|
544
|
+
style: [{ children: 'body { color: red } </style><script>' }],
|
|
545
545
|
})
|
|
546
|
-
return h(
|
|
546
|
+
return h('div', null)
|
|
547
547
|
}
|
|
548
548
|
const { head } = await renderWithHead(h(Page, null))
|
|
549
549
|
// Closing tag should be escaped
|
|
550
|
-
expect(head).toContain(
|
|
550
|
+
expect(head).toContain('<\\/style>')
|
|
551
551
|
})
|
|
552
552
|
|
|
553
|
-
test(
|
|
553
|
+
test('unkeyed tags are all preserved in resolve', () => {
|
|
554
554
|
const id1 = Symbol()
|
|
555
555
|
const id2 = Symbol()
|
|
556
|
-
ctx.add(id1, { tags: [{ tag:
|
|
557
|
-
ctx.add(id2, { tags: [{ tag:
|
|
556
|
+
ctx.add(id1, { tags: [{ tag: 'meta', props: { name: 'a', content: '1' } }] })
|
|
557
|
+
ctx.add(id2, { tags: [{ tag: 'meta', props: { name: 'b', content: '2' } }] })
|
|
558
558
|
const tags = ctx.resolve()
|
|
559
559
|
expect(tags).toHaveLength(2)
|
|
560
560
|
ctx.remove(id1)
|
|
561
561
|
ctx.remove(id2)
|
|
562
562
|
})
|
|
563
563
|
|
|
564
|
-
test(
|
|
564
|
+
test('title tag without children renders empty', async () => {
|
|
565
565
|
function Page() {
|
|
566
|
-
useHead({ title:
|
|
567
|
-
return h(
|
|
566
|
+
useHead({ title: '' })
|
|
567
|
+
return h('div', null)
|
|
568
568
|
}
|
|
569
569
|
const { head } = await renderWithHead(h(Page, null))
|
|
570
|
-
expect(head).toContain(
|
|
570
|
+
expect(head).toContain('<title></title>')
|
|
571
571
|
})
|
|
572
572
|
|
|
573
|
-
test(
|
|
573
|
+
test('useHead with no context is a no-op', () => {
|
|
574
574
|
// Calling useHead outside of any HeadProvider should not throw
|
|
575
575
|
expect(() => {
|
|
576
|
-
useHead({ title:
|
|
576
|
+
useHead({ title: 'No Provider' })
|
|
577
577
|
}).not.toThrow()
|
|
578
578
|
})
|
|
579
579
|
|
|
580
|
-
test(
|
|
580
|
+
test('CSR sync creates new elements for unkeyed tags', () => {
|
|
581
581
|
function Page() {
|
|
582
|
-
useHead({ meta: [{ name:
|
|
583
|
-
return h(
|
|
582
|
+
useHead({ meta: [{ name: 'viewport', content: 'width=device-width' }] })
|
|
583
|
+
return h('div', null)
|
|
584
584
|
}
|
|
585
585
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
586
586
|
const meta = document.head.querySelector('meta[name="viewport"]')
|
|
587
587
|
expect(meta).not.toBeNull()
|
|
588
588
|
})
|
|
589
589
|
|
|
590
|
-
test(
|
|
591
|
-
const val = signal(
|
|
590
|
+
test('CSR patchAttrs sets new attribute values', () => {
|
|
591
|
+
const val = signal('initial')
|
|
592
592
|
function Page() {
|
|
593
593
|
useHead(() => ({
|
|
594
|
-
meta: [{ name:
|
|
594
|
+
meta: [{ name: 'test-patch', content: val() }],
|
|
595
595
|
}))
|
|
596
|
-
return h(
|
|
596
|
+
return h('div', null)
|
|
597
597
|
}
|
|
598
598
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
599
599
|
const el = document.head.querySelector('meta[name="test-patch"]')
|
|
600
|
-
expect(el?.getAttribute(
|
|
601
|
-
val.set(
|
|
602
|
-
expect(el?.getAttribute(
|
|
600
|
+
expect(el?.getAttribute('content')).toBe('initial')
|
|
601
|
+
val.set('changed')
|
|
602
|
+
expect(el?.getAttribute('content')).toBe('changed')
|
|
603
603
|
})
|
|
604
604
|
|
|
605
|
-
test(
|
|
605
|
+
test('dom.ts: syncDom creates new meta element when none exists', () => {
|
|
606
606
|
function Page() {
|
|
607
|
-
useHead({ meta: [{ name:
|
|
608
|
-
return h(
|
|
607
|
+
useHead({ meta: [{ name: 'keywords', content: 'test,coverage' }] })
|
|
608
|
+
return h('div', null)
|
|
609
609
|
}
|
|
610
610
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
611
611
|
const meta = document.head.querySelector('meta[name="keywords"]')
|
|
612
612
|
expect(meta).not.toBeNull()
|
|
613
|
-
expect(meta?.getAttribute(
|
|
613
|
+
expect(meta?.getAttribute('content')).toBe('test,coverage')
|
|
614
614
|
})
|
|
615
615
|
|
|
616
|
-
test(
|
|
616
|
+
test('dom.ts: syncDom replaces element when tag name differs from found (line 41-49)', () => {
|
|
617
617
|
// Pre-create a keyed element with a different tag name
|
|
618
|
-
const existing = document.createElement(
|
|
619
|
-
existing.setAttribute(
|
|
618
|
+
const existing = document.createElement('link')
|
|
619
|
+
existing.setAttribute('data-pyreon-head', 'style-0')
|
|
620
620
|
document.head.appendChild(existing)
|
|
621
621
|
|
|
622
622
|
function Page() {
|
|
623
|
-
useHead({ style: [{ children:
|
|
624
|
-
return h(
|
|
623
|
+
useHead({ style: [{ children: '.x { color: red }' }] })
|
|
624
|
+
return h('div', null)
|
|
625
625
|
}
|
|
626
626
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
627
627
|
// The old link should be removed (stale), new style element created
|
|
628
|
-
const style = document.head.querySelector(
|
|
628
|
+
const style = document.head.querySelector('style[data-pyreon-head]')
|
|
629
629
|
expect(style).not.toBeNull()
|
|
630
|
-
expect(style?.textContent).toBe(
|
|
630
|
+
expect(style?.textContent).toBe('.x { color: red }')
|
|
631
631
|
})
|
|
632
632
|
|
|
633
|
-
test(
|
|
633
|
+
test('dom.ts: syncDom handles tag with empty key (line 34)', () => {
|
|
634
634
|
// A tag with no key should not use byKey lookup (empty key → undefined)
|
|
635
635
|
function Page() {
|
|
636
636
|
useHead({
|
|
637
|
-
meta: [{ charset:
|
|
637
|
+
meta: [{ charset: 'utf-8' }], // no name or property → key is "meta-0"
|
|
638
638
|
})
|
|
639
|
-
return h(
|
|
639
|
+
return h('div', null)
|
|
640
640
|
}
|
|
641
641
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
642
642
|
const meta = document.head.querySelector('meta[charset="utf-8"]')
|
|
643
643
|
expect(meta).not.toBeNull()
|
|
644
644
|
})
|
|
645
645
|
|
|
646
|
-
test(
|
|
647
|
-
const content = signal(
|
|
646
|
+
test('dom.ts: syncDom patches textContent when content changes (line 40)', () => {
|
|
647
|
+
const content = signal('initial content')
|
|
648
648
|
function Page() {
|
|
649
649
|
useHead(() => ({
|
|
650
650
|
style: [{ children: content() }],
|
|
651
651
|
}))
|
|
652
|
-
return h(
|
|
652
|
+
return h('div', null)
|
|
653
653
|
}
|
|
654
654
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
655
|
-
const style = document.head.querySelector(
|
|
656
|
-
expect(style?.textContent).toBe(
|
|
657
|
-
content.set(
|
|
658
|
-
expect(style?.textContent).toBe(
|
|
655
|
+
const style = document.head.querySelector('style[data-pyreon-head]')
|
|
656
|
+
expect(style?.textContent).toBe('initial content')
|
|
657
|
+
content.set('updated content')
|
|
658
|
+
expect(style?.textContent).toBe('updated content')
|
|
659
659
|
})
|
|
660
660
|
|
|
661
|
-
test(
|
|
661
|
+
test('dom.ts: syncElementAttrs removes managed-attrs tracker when all attrs removed (line 99-100)', () => {
|
|
662
662
|
const show = signal(true)
|
|
663
663
|
function Page() {
|
|
664
|
-
useHead(() => (show() ? { bodyAttrs: {
|
|
665
|
-
return h(
|
|
664
|
+
useHead(() => (show() ? { bodyAttrs: { 'data-theme': 'dark' } } : { bodyAttrs: {} }))
|
|
665
|
+
return h('div', null)
|
|
666
666
|
}
|
|
667
667
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
668
|
-
expect(document.body.getAttribute(
|
|
669
|
-
expect(document.body.getAttribute(
|
|
668
|
+
expect(document.body.getAttribute('data-theme')).toBe('dark')
|
|
669
|
+
expect(document.body.getAttribute('data-pyreon-head-attrs')).toBe('data-theme')
|
|
670
670
|
show.set(false)
|
|
671
|
-
expect(document.body.getAttribute(
|
|
671
|
+
expect(document.body.getAttribute('data-theme')).toBeNull()
|
|
672
672
|
// The managed-attrs tracker should also be removed
|
|
673
|
-
expect(document.body.getAttribute(
|
|
673
|
+
expect(document.body.getAttribute('data-pyreon-head-attrs')).toBeNull()
|
|
674
674
|
})
|
|
675
675
|
|
|
676
|
-
test(
|
|
677
|
-
const val = signal(
|
|
676
|
+
test('dom.ts: syncElementAttrs updates managed attrs tracking (lines 92-98)', () => {
|
|
677
|
+
const val = signal('en')
|
|
678
678
|
function Page() {
|
|
679
|
-
useHead(() => ({ htmlAttrs: { lang: val(),
|
|
680
|
-
return h(
|
|
679
|
+
useHead(() => ({ htmlAttrs: { lang: val(), 'data-x': 'y' } }))
|
|
680
|
+
return h('div', null)
|
|
681
681
|
}
|
|
682
682
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
683
|
-
const managed = document.documentElement.getAttribute(
|
|
684
|
-
expect(managed).toContain(
|
|
685
|
-
expect(managed).toContain(
|
|
683
|
+
const managed = document.documentElement.getAttribute('data-pyreon-head-attrs')
|
|
684
|
+
expect(managed).toContain('lang')
|
|
685
|
+
expect(managed).toContain('data-x')
|
|
686
686
|
// Update to only one attr to verify partial removal
|
|
687
|
-
val.set(
|
|
688
|
-
expect(document.documentElement.getAttribute(
|
|
687
|
+
val.set('fr')
|
|
688
|
+
expect(document.documentElement.getAttribute('lang')).toBe('fr')
|
|
689
689
|
})
|
|
690
690
|
|
|
691
|
-
test(
|
|
691
|
+
test('provider.tsx: HeadProvider handles function children (line 32)', () => {
|
|
692
692
|
function Page() {
|
|
693
|
-
useHead({ title:
|
|
694
|
-
return h(
|
|
693
|
+
useHead({ title: 'Func Children' })
|
|
694
|
+
return h('div', null, 'hello')
|
|
695
695
|
}
|
|
696
696
|
// Pass children as a function (thunk)
|
|
697
697
|
const childFn = () => h(Page, null)
|
|
698
698
|
mount(h(HeadProvider, { context: ctx, children: childFn }), container)
|
|
699
|
-
expect(document.title).toBe(
|
|
699
|
+
expect(document.title).toBe('Func Children')
|
|
700
700
|
})
|
|
701
701
|
|
|
702
|
-
test(
|
|
702
|
+
test('dom.ts: patchAttrs removes old attrs not in new props (line 67)', () => {
|
|
703
703
|
const val = signal<Record<string, string>>({
|
|
704
|
-
name:
|
|
705
|
-
content:
|
|
706
|
-
|
|
704
|
+
name: 'desc',
|
|
705
|
+
content: 'old',
|
|
706
|
+
'data-extra': 'yes',
|
|
707
707
|
})
|
|
708
708
|
function Page() {
|
|
709
709
|
useHead(() => ({ meta: [val()] }))
|
|
710
|
-
return h(
|
|
710
|
+
return h('div', null)
|
|
711
711
|
}
|
|
712
712
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
713
713
|
const el = document.head.querySelector('meta[name="desc"]')
|
|
714
|
-
expect(el?.getAttribute(
|
|
714
|
+
expect(el?.getAttribute('data-extra')).toBe('yes')
|
|
715
715
|
// Update to remove data-extra attr
|
|
716
|
-
val.set({ name:
|
|
717
|
-
expect(el?.getAttribute(
|
|
718
|
-
expect(el?.getAttribute(
|
|
716
|
+
val.set({ name: 'desc', content: 'new' })
|
|
717
|
+
expect(el?.getAttribute('data-extra')).toBeNull()
|
|
718
|
+
expect(el?.getAttribute('content')).toBe('new')
|
|
719
719
|
})
|
|
720
720
|
|
|
721
|
-
test(
|
|
721
|
+
test('dom.ts: syncDom skips textContent update when content already matches (line 40 false)', () => {
|
|
722
722
|
const trigger = signal(0)
|
|
723
723
|
function Page() {
|
|
724
724
|
useHead(() => {
|
|
725
725
|
trigger() // subscribe so effect re-runs
|
|
726
|
-
return { style: [{ children:
|
|
726
|
+
return { style: [{ children: 'unchanged' }] }
|
|
727
727
|
})
|
|
728
|
-
return h(
|
|
728
|
+
return h('div', null)
|
|
729
729
|
}
|
|
730
730
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
731
|
-
const style = document.head.querySelector(
|
|
732
|
-
expect(style?.textContent).toBe(
|
|
731
|
+
const style = document.head.querySelector('style[data-pyreon-head]')
|
|
732
|
+
expect(style?.textContent).toBe('unchanged')
|
|
733
733
|
// Re-trigger syncDom with same content — exercises the "content matches" branch
|
|
734
734
|
trigger.set(1)
|
|
735
|
-
expect(style?.textContent).toBe(
|
|
735
|
+
expect(style?.textContent).toBe('unchanged')
|
|
736
736
|
})
|
|
737
737
|
|
|
738
|
-
test(
|
|
739
|
-
const s = signal(
|
|
738
|
+
test('dom.ts: syncDom handles tag.children when content changes (line 39-40)', () => {
|
|
739
|
+
const s = signal('body1')
|
|
740
740
|
function Page() {
|
|
741
741
|
useHead(() => ({ style: [{ children: s() }] }))
|
|
742
|
-
return h(
|
|
742
|
+
return h('div', null)
|
|
743
743
|
}
|
|
744
744
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
745
|
-
const style = document.head.querySelector(
|
|
746
|
-
expect(style?.textContent).toBe(
|
|
747
|
-
s.set(
|
|
748
|
-
expect(style?.textContent).toBe(
|
|
745
|
+
const style = document.head.querySelector('style[data-pyreon-head]')
|
|
746
|
+
expect(style?.textContent).toBe('body1')
|
|
747
|
+
s.set('body2')
|
|
748
|
+
expect(style?.textContent).toBe('body2')
|
|
749
749
|
})
|
|
750
750
|
})
|
|
751
751
|
|
|
752
752
|
// ─── createHeadContext — context stacking & caching ──────────────────────────
|
|
753
753
|
|
|
754
|
-
describe(
|
|
755
|
-
test(
|
|
754
|
+
describe('createHeadContext', () => {
|
|
755
|
+
test('resolve returns empty array when no entries', () => {
|
|
756
756
|
const ctx = createHeadContext()
|
|
757
757
|
expect(ctx.resolve()).toEqual([])
|
|
758
758
|
})
|
|
759
759
|
|
|
760
|
-
test(
|
|
760
|
+
test('resolve caches result until dirty', () => {
|
|
761
761
|
const ctx = createHeadContext()
|
|
762
762
|
const id = Symbol()
|
|
763
|
-
ctx.add(id, { tags: [{ tag:
|
|
763
|
+
ctx.add(id, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
|
|
764
764
|
const first = ctx.resolve()
|
|
765
765
|
const second = ctx.resolve()
|
|
766
766
|
expect(first).toBe(second) // same array reference — cached
|
|
767
767
|
})
|
|
768
768
|
|
|
769
|
-
test(
|
|
769
|
+
test('resolve invalidates cache after add', () => {
|
|
770
770
|
const ctx = createHeadContext()
|
|
771
771
|
const id1 = Symbol()
|
|
772
|
-
ctx.add(id1, { tags: [{ tag:
|
|
772
|
+
ctx.add(id1, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
|
|
773
773
|
const first = ctx.resolve()
|
|
774
774
|
const id2 = Symbol()
|
|
775
|
-
ctx.add(id2, { tags: [{ tag:
|
|
775
|
+
ctx.add(id2, { tags: [{ tag: 'meta', key: 'b', props: { name: 'b' } }] })
|
|
776
776
|
const second = ctx.resolve()
|
|
777
777
|
expect(first).not.toBe(second) // different reference — rebuilt
|
|
778
778
|
expect(second).toHaveLength(2)
|
|
779
779
|
})
|
|
780
780
|
|
|
781
|
-
test(
|
|
781
|
+
test('resolve invalidates cache after remove', () => {
|
|
782
782
|
const ctx = createHeadContext()
|
|
783
783
|
const id = Symbol()
|
|
784
|
-
ctx.add(id, { tags: [{ tag:
|
|
784
|
+
ctx.add(id, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
|
|
785
785
|
const first = ctx.resolve()
|
|
786
786
|
ctx.remove(id)
|
|
787
787
|
const second = ctx.resolve()
|
|
@@ -789,59 +789,59 @@ describe("createHeadContext", () => {
|
|
|
789
789
|
expect(first).not.toBe(second)
|
|
790
790
|
})
|
|
791
791
|
|
|
792
|
-
test(
|
|
792
|
+
test('keyed tags deduplicate — last added wins', () => {
|
|
793
793
|
const ctx = createHeadContext()
|
|
794
794
|
const id1 = Symbol()
|
|
795
795
|
const id2 = Symbol()
|
|
796
|
-
ctx.add(id1, { tags: [{ tag:
|
|
797
|
-
ctx.add(id2, { tags: [{ tag:
|
|
796
|
+
ctx.add(id1, { tags: [{ tag: 'title', key: 'title', children: 'First' }] })
|
|
797
|
+
ctx.add(id2, { tags: [{ tag: 'title', key: 'title', children: 'Second' }] })
|
|
798
798
|
const tags = ctx.resolve()
|
|
799
799
|
expect(tags).toHaveLength(1)
|
|
800
|
-
expect(tags[0]?.children).toBe(
|
|
800
|
+
expect(tags[0]?.children).toBe('Second')
|
|
801
801
|
})
|
|
802
802
|
|
|
803
|
-
test(
|
|
803
|
+
test('resolveTitleTemplate returns undefined when none set', () => {
|
|
804
804
|
const ctx = createHeadContext()
|
|
805
805
|
expect(ctx.resolveTitleTemplate()).toBeUndefined()
|
|
806
806
|
})
|
|
807
807
|
|
|
808
|
-
test(
|
|
808
|
+
test('resolveTitleTemplate returns last added template', () => {
|
|
809
809
|
const ctx = createHeadContext()
|
|
810
810
|
const id1 = Symbol()
|
|
811
811
|
const id2 = Symbol()
|
|
812
|
-
ctx.add(id1, { tags: [], titleTemplate:
|
|
813
|
-
ctx.add(id2, { tags: [], titleTemplate:
|
|
814
|
-
expect(ctx.resolveTitleTemplate()).toBe(
|
|
812
|
+
ctx.add(id1, { tags: [], titleTemplate: '%s | Site A' })
|
|
813
|
+
ctx.add(id2, { tags: [], titleTemplate: '%s | Site B' })
|
|
814
|
+
expect(ctx.resolveTitleTemplate()).toBe('%s | Site B')
|
|
815
815
|
})
|
|
816
816
|
|
|
817
|
-
test(
|
|
817
|
+
test('resolveHtmlAttrs merges from multiple entries', () => {
|
|
818
818
|
const ctx = createHeadContext()
|
|
819
819
|
const id1 = Symbol()
|
|
820
820
|
const id2 = Symbol()
|
|
821
|
-
ctx.add(id1, { tags: [], htmlAttrs: { lang:
|
|
822
|
-
ctx.add(id2, { tags: [], htmlAttrs: { dir:
|
|
823
|
-
expect(ctx.resolveHtmlAttrs()).toEqual({ lang:
|
|
821
|
+
ctx.add(id1, { tags: [], htmlAttrs: { lang: 'en' } })
|
|
822
|
+
ctx.add(id2, { tags: [], htmlAttrs: { dir: 'ltr' } })
|
|
823
|
+
expect(ctx.resolveHtmlAttrs()).toEqual({ lang: 'en', dir: 'ltr' })
|
|
824
824
|
})
|
|
825
825
|
|
|
826
|
-
test(
|
|
826
|
+
test('resolveHtmlAttrs later entries override earlier', () => {
|
|
827
827
|
const ctx = createHeadContext()
|
|
828
828
|
const id1 = Symbol()
|
|
829
829
|
const id2 = Symbol()
|
|
830
|
-
ctx.add(id1, { tags: [], htmlAttrs: { lang:
|
|
831
|
-
ctx.add(id2, { tags: [], htmlAttrs: { lang:
|
|
832
|
-
expect(ctx.resolveHtmlAttrs()).toEqual({ lang:
|
|
830
|
+
ctx.add(id1, { tags: [], htmlAttrs: { lang: 'en' } })
|
|
831
|
+
ctx.add(id2, { tags: [], htmlAttrs: { lang: 'fr' } })
|
|
832
|
+
expect(ctx.resolveHtmlAttrs()).toEqual({ lang: 'fr' })
|
|
833
833
|
})
|
|
834
834
|
|
|
835
|
-
test(
|
|
835
|
+
test('resolveBodyAttrs merges from multiple entries', () => {
|
|
836
836
|
const ctx = createHeadContext()
|
|
837
837
|
const id1 = Symbol()
|
|
838
838
|
const id2 = Symbol()
|
|
839
|
-
ctx.add(id1, { tags: [], bodyAttrs: { class:
|
|
840
|
-
ctx.add(id2, { tags: [], bodyAttrs: {
|
|
841
|
-
expect(ctx.resolveBodyAttrs()).toEqual({ class:
|
|
839
|
+
ctx.add(id1, { tags: [], bodyAttrs: { class: 'dark' } })
|
|
840
|
+
ctx.add(id2, { tags: [], bodyAttrs: { 'data-page': 'home' } })
|
|
841
|
+
expect(ctx.resolveBodyAttrs()).toEqual({ class: 'dark', 'data-page': 'home' })
|
|
842
842
|
})
|
|
843
843
|
|
|
844
|
-
test(
|
|
844
|
+
test('remove non-existent id does not throw', () => {
|
|
845
845
|
const ctx = createHeadContext()
|
|
846
846
|
expect(() => ctx.remove(Symbol())).not.toThrow()
|
|
847
847
|
})
|
|
@@ -849,316 +849,316 @@ describe("createHeadContext", () => {
|
|
|
849
849
|
|
|
850
850
|
// ─── HeadProvider — context stacking ─────────────────────────────────────────
|
|
851
851
|
|
|
852
|
-
describe(
|
|
852
|
+
describe('HeadProvider — context stacking', () => {
|
|
853
853
|
let container: HTMLElement
|
|
854
854
|
|
|
855
855
|
beforeEach(() => {
|
|
856
|
-
container = document.createElement(
|
|
856
|
+
container = document.createElement('div')
|
|
857
857
|
document.body.appendChild(container)
|
|
858
|
-
for (const el of document.head.querySelectorAll(
|
|
859
|
-
document.title =
|
|
858
|
+
for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
|
|
859
|
+
document.title = ''
|
|
860
860
|
})
|
|
861
861
|
|
|
862
|
-
test(
|
|
862
|
+
test('auto-creates context when no context prop', () => {
|
|
863
863
|
function Page() {
|
|
864
|
-
useHead({ title:
|
|
865
|
-
return h(
|
|
864
|
+
useHead({ title: 'Auto Context' })
|
|
865
|
+
return h('div', null)
|
|
866
866
|
}
|
|
867
867
|
// HeadProvider without context prop
|
|
868
868
|
mount(h(HeadProvider, { children: h(Page, null) }), container)
|
|
869
|
-
expect(document.title).toBe(
|
|
869
|
+
expect(document.title).toBe('Auto Context')
|
|
870
870
|
})
|
|
871
871
|
|
|
872
|
-
test(
|
|
872
|
+
test('nested HeadProviders — inner context receives inner useHead calls', () => {
|
|
873
873
|
const outerCtx = createHeadContext()
|
|
874
874
|
const innerCtx = createHeadContext()
|
|
875
875
|
|
|
876
876
|
function Outer() {
|
|
877
|
-
useHead({ title:
|
|
878
|
-
return h(
|
|
877
|
+
useHead({ title: 'Outer' })
|
|
878
|
+
return h('div', null, h(HeadProvider, { context: innerCtx, children: h(Inner, null) }))
|
|
879
879
|
}
|
|
880
880
|
function Inner() {
|
|
881
|
-
useHead({ title:
|
|
882
|
-
return h(
|
|
881
|
+
useHead({ title: 'Inner' })
|
|
882
|
+
return h('span', null)
|
|
883
883
|
}
|
|
884
884
|
mount(h(HeadProvider, { context: outerCtx, children: h(Outer, null) }), container)
|
|
885
885
|
// Outer context has "Outer" title, inner context has "Inner" title
|
|
886
886
|
// The outer syncDom runs and sets title to "Outer"
|
|
887
887
|
// Both contexts sync independently
|
|
888
888
|
const outerTags = outerCtx.resolve()
|
|
889
|
-
expect(outerTags.some((t) => t.children ===
|
|
889
|
+
expect(outerTags.some((t) => t.children === 'Outer')).toBe(true)
|
|
890
890
|
// The inner context should have Inner's title registered
|
|
891
891
|
const innerTags = innerCtx.resolve()
|
|
892
|
-
expect(innerTags.some((t) => t.children ===
|
|
892
|
+
expect(innerTags.some((t) => t.children === 'Inner')).toBe(true)
|
|
893
893
|
})
|
|
894
894
|
})
|
|
895
895
|
|
|
896
896
|
// ─── useHead with reactive signals — CSR ────────────────────────────────────
|
|
897
897
|
|
|
898
|
-
describe(
|
|
898
|
+
describe('useHead — reactive signal-driven values', () => {
|
|
899
899
|
let container: HTMLElement
|
|
900
900
|
let ctx: HeadContextValue
|
|
901
901
|
|
|
902
902
|
beforeEach(() => {
|
|
903
|
-
container = document.createElement(
|
|
903
|
+
container = document.createElement('div')
|
|
904
904
|
document.body.appendChild(container)
|
|
905
905
|
ctx = createHeadContext()
|
|
906
|
-
for (const el of document.head.querySelectorAll(
|
|
907
|
-
document.title =
|
|
906
|
+
for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
|
|
907
|
+
document.title = ''
|
|
908
908
|
})
|
|
909
909
|
|
|
910
|
-
test(
|
|
911
|
-
const description = signal(
|
|
910
|
+
test('reactive meta tags update when signal changes', () => {
|
|
911
|
+
const description = signal('Initial description')
|
|
912
912
|
function Page() {
|
|
913
913
|
useHead(() => ({
|
|
914
|
-
meta: [{ name:
|
|
914
|
+
meta: [{ name: 'description', content: description() }],
|
|
915
915
|
}))
|
|
916
|
-
return h(
|
|
916
|
+
return h('div', null)
|
|
917
917
|
}
|
|
918
918
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
919
|
-
expect(document.head.querySelector('meta[name="description"]')?.getAttribute(
|
|
920
|
-
|
|
919
|
+
expect(document.head.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
|
|
920
|
+
'Initial description',
|
|
921
921
|
)
|
|
922
|
-
description.set(
|
|
923
|
-
expect(document.head.querySelector('meta[name="description"]')?.getAttribute(
|
|
924
|
-
|
|
922
|
+
description.set('Updated description')
|
|
923
|
+
expect(document.head.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
|
|
924
|
+
'Updated description',
|
|
925
925
|
)
|
|
926
926
|
})
|
|
927
927
|
|
|
928
|
-
test(
|
|
929
|
-
const href = signal(
|
|
928
|
+
test('reactive link tags update when signal changes', () => {
|
|
929
|
+
const href = signal('/page-v1')
|
|
930
930
|
function Page() {
|
|
931
931
|
useHead(() => ({
|
|
932
|
-
link: [{ rel:
|
|
932
|
+
link: [{ rel: 'canonical', href: href() }],
|
|
933
933
|
}))
|
|
934
|
-
return h(
|
|
934
|
+
return h('div', null)
|
|
935
935
|
}
|
|
936
936
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
937
937
|
const link = document.head.querySelector('link[rel="canonical"]')
|
|
938
|
-
expect(link?.getAttribute(
|
|
939
|
-
href.set(
|
|
938
|
+
expect(link?.getAttribute('href')).toBe('/page-v1')
|
|
939
|
+
href.set('/page-v2')
|
|
940
940
|
// A new link element is created because the key changes (includes href)
|
|
941
|
-
const newLink = document.head.querySelector(
|
|
941
|
+
const newLink = document.head.querySelector('link[data-pyreon-head]')
|
|
942
942
|
expect(newLink).not.toBeNull()
|
|
943
943
|
})
|
|
944
944
|
|
|
945
|
-
test(
|
|
946
|
-
const theme = signal(
|
|
945
|
+
test('reactive bodyAttrs update when signal changes', () => {
|
|
946
|
+
const theme = signal('light')
|
|
947
947
|
function Page() {
|
|
948
|
-
useHead(() => ({ bodyAttrs: {
|
|
949
|
-
return h(
|
|
948
|
+
useHead(() => ({ bodyAttrs: { 'data-theme': theme() } }))
|
|
949
|
+
return h('div', null)
|
|
950
950
|
}
|
|
951
951
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
952
|
-
expect(document.body.getAttribute(
|
|
953
|
-
theme.set(
|
|
954
|
-
expect(document.body.getAttribute(
|
|
952
|
+
expect(document.body.getAttribute('data-theme')).toBe('light')
|
|
953
|
+
theme.set('dark')
|
|
954
|
+
expect(document.body.getAttribute('data-theme')).toBe('dark')
|
|
955
955
|
})
|
|
956
956
|
|
|
957
|
-
test(
|
|
958
|
-
const lang = signal(
|
|
957
|
+
test('reactive htmlAttrs update when signal changes', () => {
|
|
958
|
+
const lang = signal('en')
|
|
959
959
|
function Page() {
|
|
960
960
|
useHead(() => ({ htmlAttrs: { lang: lang() } }))
|
|
961
|
-
return h(
|
|
961
|
+
return h('div', null)
|
|
962
962
|
}
|
|
963
963
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
964
|
-
expect(document.documentElement.getAttribute(
|
|
965
|
-
lang.set(
|
|
966
|
-
expect(document.documentElement.getAttribute(
|
|
964
|
+
expect(document.documentElement.getAttribute('lang')).toBe('en')
|
|
965
|
+
lang.set('de')
|
|
966
|
+
expect(document.documentElement.getAttribute('lang')).toBe('de')
|
|
967
967
|
})
|
|
968
968
|
|
|
969
|
-
test(
|
|
970
|
-
const pageName = signal(
|
|
969
|
+
test('reactive jsonLd updates when signal changes', () => {
|
|
970
|
+
const pageName = signal('Home')
|
|
971
971
|
function Page() {
|
|
972
|
-
useHead(() => ({ jsonLd: {
|
|
973
|
-
return h(
|
|
972
|
+
useHead(() => ({ jsonLd: { '@type': 'WebPage', name: pageName() } }))
|
|
973
|
+
return h('div', null)
|
|
974
974
|
}
|
|
975
975
|
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
976
976
|
const script = document.head.querySelector('script[type="application/ld+json"]')
|
|
977
977
|
expect(script?.textContent).toContain('"name":"Home"')
|
|
978
|
-
pageName.set(
|
|
978
|
+
pageName.set('About')
|
|
979
979
|
const updated = document.head.querySelector('script[type="application/ld+json"]')
|
|
980
980
|
expect(updated?.textContent).toContain('"name":"About"')
|
|
981
981
|
})
|
|
982
982
|
|
|
983
|
-
test(
|
|
984
|
-
const pageTitle = signal(
|
|
983
|
+
test('reactive titleTemplate with signal-driven title', () => {
|
|
984
|
+
const pageTitle = signal('Home')
|
|
985
985
|
function Layout() {
|
|
986
|
-
useHead({ titleTemplate:
|
|
987
|
-
return h(
|
|
986
|
+
useHead({ titleTemplate: '%s | MySite' })
|
|
987
|
+
return h('div', null, h(Page, null))
|
|
988
988
|
}
|
|
989
989
|
function Page() {
|
|
990
990
|
useHead(() => ({ title: pageTitle() }))
|
|
991
|
-
return h(
|
|
991
|
+
return h('span', null)
|
|
992
992
|
}
|
|
993
993
|
mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
|
|
994
|
-
expect(document.title).toBe(
|
|
995
|
-
pageTitle.set(
|
|
996
|
-
expect(document.title).toBe(
|
|
994
|
+
expect(document.title).toBe('Home | MySite')
|
|
995
|
+
pageTitle.set('About')
|
|
996
|
+
expect(document.title).toBe('About | MySite')
|
|
997
997
|
})
|
|
998
998
|
})
|
|
999
999
|
|
|
1000
1000
|
// ─── renderWithHead — SSR subpath import ─────────────────────────────────────
|
|
1001
1001
|
|
|
1002
|
-
describe(
|
|
1003
|
-
test(
|
|
1004
|
-
const mod = await import(
|
|
1005
|
-
expect(typeof mod.renderWithHead).toBe(
|
|
1002
|
+
describe('renderWithHead — @pyreon/head/ssr subpath', () => {
|
|
1003
|
+
test('renderWithHead is importable from ssr module', async () => {
|
|
1004
|
+
const mod = await import('../ssr')
|
|
1005
|
+
expect(typeof mod.renderWithHead).toBe('function')
|
|
1006
1006
|
})
|
|
1007
1007
|
|
|
1008
|
-
test(
|
|
1008
|
+
test('renderWithHead with multiple useHead calls merges tags', async () => {
|
|
1009
1009
|
function Layout() {
|
|
1010
|
-
useHead({ titleTemplate:
|
|
1011
|
-
return h(
|
|
1010
|
+
useHead({ titleTemplate: '%s | App', htmlAttrs: { lang: 'en' } })
|
|
1011
|
+
return h('div', null, h(Page, null))
|
|
1012
1012
|
}
|
|
1013
1013
|
function Page() {
|
|
1014
1014
|
useHead({
|
|
1015
|
-
title:
|
|
1016
|
-
meta: [{ name:
|
|
1017
|
-
bodyAttrs: { class:
|
|
1015
|
+
title: 'Dashboard',
|
|
1016
|
+
meta: [{ name: 'description', content: 'Dashboard page' }],
|
|
1017
|
+
bodyAttrs: { class: 'dashboard' },
|
|
1018
1018
|
})
|
|
1019
|
-
return h(
|
|
1019
|
+
return h('span', null)
|
|
1020
1020
|
}
|
|
1021
1021
|
const result = await renderWithHead(h(Layout, null))
|
|
1022
|
-
expect(result.head).toContain(
|
|
1022
|
+
expect(result.head).toContain('<title>Dashboard | App</title>')
|
|
1023
1023
|
expect(result.head).toContain('name="description"')
|
|
1024
|
-
expect(result.htmlAttrs).toEqual({ lang:
|
|
1025
|
-
expect(result.bodyAttrs).toEqual({ class:
|
|
1024
|
+
expect(result.htmlAttrs).toEqual({ lang: 'en' })
|
|
1025
|
+
expect(result.bodyAttrs).toEqual({ class: 'dashboard' })
|
|
1026
1026
|
})
|
|
1027
1027
|
|
|
1028
|
-
test(
|
|
1028
|
+
test('renderWithHead with empty head returns empty string', async () => {
|
|
1029
1029
|
function Page() {
|
|
1030
|
-
return h(
|
|
1030
|
+
return h('div', null, 'content')
|
|
1031
1031
|
}
|
|
1032
1032
|
const result = await renderWithHead(h(Page, null))
|
|
1033
|
-
expect(result.head).toBe(
|
|
1034
|
-
expect(result.html).toContain(
|
|
1033
|
+
expect(result.head).toBe('')
|
|
1034
|
+
expect(result.html).toContain('content')
|
|
1035
1035
|
})
|
|
1036
1036
|
|
|
1037
|
-
test(
|
|
1037
|
+
test('renderWithHead serializes HTML comment openers in script content', async () => {
|
|
1038
1038
|
function Page() {
|
|
1039
|
-
useHead({ script: [{ children:
|
|
1040
|
-
return h(
|
|
1039
|
+
useHead({ script: [{ children: 'if (x <!-- y) {}' }] })
|
|
1040
|
+
return h('div', null)
|
|
1041
1041
|
}
|
|
1042
1042
|
const { head } = await renderWithHead(h(Page, null))
|
|
1043
|
-
expect(head).toContain(
|
|
1044
|
-
expect(head).not.toContain(
|
|
1043
|
+
expect(head).toContain('<\\!--')
|
|
1044
|
+
expect(head).not.toContain('<!--')
|
|
1045
1045
|
})
|
|
1046
1046
|
})
|
|
1047
1047
|
|
|
1048
1048
|
// ─── SSR — additional branch coverage ────────────────────────────────────────
|
|
1049
1049
|
|
|
1050
|
-
describe(
|
|
1051
|
-
test(
|
|
1050
|
+
describe('renderWithHead — SSR additional branches', () => {
|
|
1051
|
+
test('ssr.ts: title without children (line 60)', async () => {
|
|
1052
1052
|
function Page() {
|
|
1053
1053
|
useHead({} as any) // no title field at all
|
|
1054
|
-
return h(
|
|
1054
|
+
return h('div', null)
|
|
1055
1055
|
}
|
|
1056
1056
|
const { head } = await renderWithHead(h(Page, null))
|
|
1057
|
-
expect(head).toBe(
|
|
1057
|
+
expect(head).toBe('') // no tags
|
|
1058
1058
|
})
|
|
1059
1059
|
|
|
1060
|
-
test(
|
|
1060
|
+
test('ssr.ts: non-raw tag with children (line 76)', async () => {
|
|
1061
1061
|
function Page() {
|
|
1062
1062
|
useHead({
|
|
1063
|
-
noscript: [{ children:
|
|
1063
|
+
noscript: [{ children: '<p>Enable JavaScript</p>' }],
|
|
1064
1064
|
})
|
|
1065
|
-
return h(
|
|
1065
|
+
return h('div', null)
|
|
1066
1066
|
}
|
|
1067
1067
|
const { head } = await renderWithHead(h(Page, null))
|
|
1068
|
-
expect(head).toContain(
|
|
1069
|
-
expect(head).toContain(
|
|
1068
|
+
expect(head).toContain('<noscript>')
|
|
1069
|
+
expect(head).toContain('Enable JavaScript')
|
|
1070
1070
|
})
|
|
1071
1071
|
|
|
1072
|
-
test(
|
|
1072
|
+
test('ssr.ts: function titleTemplate in SSR', async () => {
|
|
1073
1073
|
function Page() {
|
|
1074
1074
|
useHead({
|
|
1075
|
-
title:
|
|
1075
|
+
title: 'Page Title',
|
|
1076
1076
|
titleTemplate: (t: string) => `${t} | MySite`,
|
|
1077
1077
|
})
|
|
1078
|
-
return h(
|
|
1078
|
+
return h('div', null)
|
|
1079
1079
|
}
|
|
1080
1080
|
const { head } = await renderWithHead(h(Page, null))
|
|
1081
|
-
expect(head).toContain(
|
|
1081
|
+
expect(head).toContain('Page Title | MySite')
|
|
1082
1082
|
})
|
|
1083
1083
|
|
|
1084
|
-
test(
|
|
1084
|
+
test('ssr.ts: string titleTemplate in SSR', async () => {
|
|
1085
1085
|
function Page() {
|
|
1086
1086
|
useHead({
|
|
1087
|
-
title:
|
|
1088
|
-
titleTemplate:
|
|
1087
|
+
title: 'Page',
|
|
1088
|
+
titleTemplate: '%s - App',
|
|
1089
1089
|
})
|
|
1090
|
-
return h(
|
|
1090
|
+
return h('div', null)
|
|
1091
1091
|
}
|
|
1092
1092
|
const { head } = await renderWithHead(h(Page, null))
|
|
1093
|
-
expect(head).toContain(
|
|
1093
|
+
expect(head).toContain('Page - App')
|
|
1094
1094
|
})
|
|
1095
1095
|
|
|
1096
|
-
test(
|
|
1096
|
+
test('use-head.ts: reactive input in SSR evaluates once (line 86-88)', async () => {
|
|
1097
1097
|
function Page() {
|
|
1098
1098
|
useHead(() => ({
|
|
1099
|
-
title:
|
|
1100
|
-
meta: [{ name:
|
|
1099
|
+
title: 'SSR Reactive',
|
|
1100
|
+
meta: [{ name: 'desc', content: 'from function' }],
|
|
1101
1101
|
}))
|
|
1102
|
-
return h(
|
|
1102
|
+
return h('div', null)
|
|
1103
1103
|
}
|
|
1104
1104
|
const { head } = await renderWithHead(h(Page, null))
|
|
1105
|
-
expect(head).toContain(
|
|
1105
|
+
expect(head).toContain('SSR Reactive')
|
|
1106
1106
|
})
|
|
1107
1107
|
|
|
1108
|
-
test(
|
|
1108
|
+
test('use-head.ts: link key without href falls back to rel (line 20)', async () => {
|
|
1109
1109
|
function Page() {
|
|
1110
1110
|
useHead({
|
|
1111
1111
|
link: [
|
|
1112
|
-
{ rel:
|
|
1113
|
-
{ rel:
|
|
1112
|
+
{ rel: 'preconnect' }, // no href → key uses rel
|
|
1113
|
+
{ rel: 'dns-prefetch' }, // another no-href
|
|
1114
1114
|
],
|
|
1115
1115
|
})
|
|
1116
|
-
return h(
|
|
1116
|
+
return h('div', null)
|
|
1117
1117
|
}
|
|
1118
1118
|
const { head } = await renderWithHead(h(Page, null))
|
|
1119
|
-
expect(head).toContain(
|
|
1120
|
-
expect(head).toContain(
|
|
1119
|
+
expect(head).toContain('preconnect')
|
|
1120
|
+
expect(head).toContain('dns-prefetch')
|
|
1121
1121
|
})
|
|
1122
1122
|
|
|
1123
|
-
test(
|
|
1123
|
+
test('use-head.ts: link key without href or rel falls back to index (line 20)', async () => {
|
|
1124
1124
|
function Page() {
|
|
1125
1125
|
useHead({
|
|
1126
|
-
link: [{ type:
|
|
1126
|
+
link: [{ type: 'text/css' }], // no href, no rel → key is "link-0"
|
|
1127
1127
|
})
|
|
1128
|
-
return h(
|
|
1128
|
+
return h('div', null)
|
|
1129
1129
|
}
|
|
1130
1130
|
const { head } = await renderWithHead(h(Page, null))
|
|
1131
|
-
expect(head).toContain(
|
|
1131
|
+
expect(head).toContain('text/css')
|
|
1132
1132
|
})
|
|
1133
1133
|
|
|
1134
|
-
test(
|
|
1134
|
+
test('use-head.ts: script with children in SSR (line 30)', async () => {
|
|
1135
1135
|
function Page() {
|
|
1136
1136
|
useHead({
|
|
1137
1137
|
script: [{ children: "console.log('hi')" }],
|
|
1138
1138
|
})
|
|
1139
|
-
return h(
|
|
1139
|
+
return h('div', null)
|
|
1140
1140
|
}
|
|
1141
1141
|
const { head } = await renderWithHead(h(Page, null))
|
|
1142
|
-
expect(head).toContain(
|
|
1142
|
+
expect(head).toContain('console.log')
|
|
1143
1143
|
})
|
|
1144
1144
|
|
|
1145
|
-
test(
|
|
1145
|
+
test('ssr.ts: htmlAttrs and bodyAttrs in result', async () => {
|
|
1146
1146
|
function Page() {
|
|
1147
1147
|
useHead({
|
|
1148
|
-
htmlAttrs: { lang:
|
|
1149
|
-
bodyAttrs: { class:
|
|
1148
|
+
htmlAttrs: { lang: 'en', dir: 'ltr' },
|
|
1149
|
+
bodyAttrs: { class: 'dark' },
|
|
1150
1150
|
})
|
|
1151
|
-
return h(
|
|
1151
|
+
return h('div', null)
|
|
1152
1152
|
}
|
|
1153
1153
|
const { htmlAttrs, bodyAttrs } = await renderWithHead(h(Page, null))
|
|
1154
|
-
expect(htmlAttrs.lang).toBe(
|
|
1155
|
-
expect(bodyAttrs.class).toBe(
|
|
1154
|
+
expect(htmlAttrs.lang).toBe('en')
|
|
1155
|
+
expect(bodyAttrs.class).toBe('dark')
|
|
1156
1156
|
})
|
|
1157
1157
|
})
|
|
1158
1158
|
|
|
1159
1159
|
// ─── SSR paths via document mocking ──────────────────────────────────────────
|
|
1160
1160
|
|
|
1161
|
-
describe(
|
|
1161
|
+
describe('useHead — SSR paths (document undefined)', () => {
|
|
1162
1162
|
const origDoc = globalThis.document
|
|
1163
1163
|
|
|
1164
1164
|
beforeEach(() => {
|
|
@@ -1169,19 +1169,21 @@ describe("useHead — SSR paths (document undefined)", () => {
|
|
|
1169
1169
|
globalThis.document = origDoc
|
|
1170
1170
|
})
|
|
1171
1171
|
|
|
1172
|
-
test(
|
|
1173
|
-
const { syncDom } = await import(
|
|
1172
|
+
test('syncDom is no-op when document is undefined (dom.ts line 13)', async () => {
|
|
1173
|
+
const { syncDom } = await import('../dom')
|
|
1174
1174
|
const ctx2 = createHeadContext()
|
|
1175
|
-
ctx2.add(Symbol(), {
|
|
1175
|
+
ctx2.add(Symbol(), {
|
|
1176
|
+
tags: [{ tag: 'meta', key: 'test', props: { name: 'x', content: 'y' } }],
|
|
1177
|
+
})
|
|
1176
1178
|
// Should not throw — early return because document is undefined
|
|
1177
1179
|
syncDom(ctx2)
|
|
1178
1180
|
})
|
|
1179
1181
|
|
|
1180
|
-
test(
|
|
1182
|
+
test('useHead static input registers synchronously in SSR (use-head.ts line 88-92)', async () => {
|
|
1181
1183
|
// In SSR (no document), static input doesn't trigger syncDom
|
|
1182
|
-
const { useHead: _uh } = await import(
|
|
1184
|
+
const { useHead: _uh } = await import('../use-head')
|
|
1183
1185
|
// This just verifies the code path doesn't error when document is undefined
|
|
1184
1186
|
// The actual registration happens via the context
|
|
1185
|
-
expect(typeof document).toBe(
|
|
1187
|
+
expect(typeof document).toBe('undefined')
|
|
1186
1188
|
})
|
|
1187
1189
|
})
|