@pyreon/head 0.12.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/head",
3
- "version": "0.12.13",
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.13",
61
- "@pyreon/reactivity": "^0.12.13"
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.13",
66
- "@pyreon/runtime-server": "^0.12.13"
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.13"
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
+ })