@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/src/provider.ts DELETED
@@ -1,65 +0,0 @@
1
- import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
- import { nativeCompat, provide, useContext } from '@pyreon/core'
3
- import type { HeadContextValue } from './context'
4
- import { createHeadContext, HeadContext } from './context'
5
-
6
- export interface HeadProviderProps extends Props {
7
- context?: HeadContextValue | undefined
8
- children?: VNodeChild
9
- }
10
-
11
- /**
12
- * Provides a HeadContextValue to all descendant components.
13
- * Wrap your app root with this to enable useHead() throughout the tree.
14
- *
15
- * Resolution order (first non-null wins):
16
- * 1. `props.context` — explicit context (documented SSR pattern).
17
- * 2. An outer `HeadContext` already in scope — inherited transparently.
18
- * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
19
- * work without manual context plumbing: `renderWithHead` pushes its
20
- * own `HeadContext` onto the per-request stack, and a nested
21
- * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
22
- * inherits it instead of silently shadowing it with a fresh,
23
- * write-only registry.
24
- * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
25
- *
26
- * The inheritance step is load-bearing for any consumer wrapping
27
- * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
28
- * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
29
- * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
30
- * `context` prop. Without inheritance, all `useHead()` calls in the
31
- * subtree wrote tags into the inner ctx while `renderWithHead` resolved
32
- * the outer ctx — producing an empty `<head>` for the whole app.
33
- *
34
- * Apps that genuinely need an isolated registry (e.g. iframe / micro-
35
- * frontend boundaries) can still opt out by passing
36
- * `context={createHeadContext()}` explicitly — `props.context` always wins.
37
- *
38
- * @example
39
- * // Auto-create context (root of a CSR app):
40
- * <HeadProvider><App /></HeadProvider>
41
- *
42
- * // Explicit context (e.g. for SSR):
43
- * const headCtx = createHeadContext()
44
- * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
45
- *
46
- * // Composes with `renderWithHead` out of the box — no plumbing needed:
47
- * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
48
- */
49
- export const HeadProvider: ComponentFn<HeadProviderProps> = (props) => {
50
- // `useContext(HeadContext)` returns `null` when no outer provider exists
51
- // (the context's defaultValue). The `??` chain therefore resolves to:
52
- // explicit prop → inherited outer ctx → fresh ctx
53
- // and `provide()` re-pushes the same ctx for the subtree (harmless: the
54
- // descendant `useContext` walk finds it identically via either frame).
55
- const ctx = props.context ?? useContext(HeadContext) ?? createHeadContext()
56
- provide(HeadContext, ctx)
57
-
58
- const ch = props.children
59
- return typeof ch === 'function' ? (ch as () => VNodeChild)() : ch
60
- }
61
-
62
- // Mark as native — compat-mode jsx() runtimes skip wrapCompatComponent so
63
- // HeadProvider's provide(HeadContext, ...) call runs inside Pyreon's setup
64
- // frame, not the compat wrapper's runUntracked accessor.
65
- nativeCompat(HeadProvider)
package/src/ssr.ts DELETED
@@ -1,89 +0,0 @@
1
- import type { ComponentFn, VNode } from '@pyreon/core'
2
- import { h, pushContext } from '@pyreon/core'
3
- import { renderToString } from '@pyreon/runtime-server'
4
- import type { HeadTag } from './context'
5
- import { createHeadContext, HeadContext } from './context'
6
-
7
- const VOID_TAGS = new Set(['meta', 'link', 'base'])
8
-
9
- /**
10
- * Render a Pyreon app to an HTML fragment + a serialized <head> string.
11
- *
12
- * The returned `head` string can be injected directly into your HTML template:
13
- *
14
- * @example
15
- * const { html, head } = await renderWithHead(h(App, null))
16
- * const page = `<!DOCTYPE html>
17
- * <html>
18
- * <head>
19
- * <meta charset="UTF-8" />
20
- * ${head}
21
- * </head>
22
- * <body><div id="app">${html}</div></body>
23
- * </html>`
24
- */
25
- export interface RenderWithHeadResult {
26
- html: string
27
- head: string
28
- /** Attributes to set on the <html> element */
29
- htmlAttrs: Record<string, string>
30
- /** Attributes to set on the <body> element */
31
- bodyAttrs: Record<string, string>
32
- }
33
-
34
- export async function renderWithHead(app: VNode): Promise<RenderWithHeadResult> {
35
- const ctx = createHeadContext()
36
-
37
- // HeadInjector runs inside renderToString's ALS scope, so pushContext reaches
38
- // the per-request context stack rather than the module-level fallback stack.
39
- function HeadInjector(): VNode {
40
- pushContext(new Map([[HeadContext.id, ctx]]))
41
- return app
42
- }
43
-
44
- const html = await renderToString(h(HeadInjector as ComponentFn, null))
45
- const titleTemplate = ctx.resolveTitleTemplate()
46
- const head = ctx
47
- .resolve()
48
- .map((tag) => serializeTag(tag, titleTemplate))
49
- .join('\n ')
50
- return {
51
- html,
52
- head,
53
- htmlAttrs: ctx.resolveHtmlAttrs(),
54
- bodyAttrs: ctx.resolveBodyAttrs(),
55
- }
56
- }
57
-
58
- function serializeTag(tag: HeadTag, titleTemplate?: string | ((title: string) => string)): string {
59
- if (tag.tag === 'title') {
60
- const raw = tag.children || ''
61
- const title = titleTemplate
62
- ? typeof titleTemplate === 'function'
63
- ? titleTemplate(raw)
64
- : titleTemplate.replace(/%s/g, raw)
65
- : raw
66
- return `<title>${esc(title)}</title>`
67
- }
68
- const props = tag.props as Record<string, string> | undefined
69
- const attrs = props
70
- ? Object.entries(props)
71
- .map(([k, v]) => `${k}="${esc(v)}"`)
72
- .join(' ')
73
- : ''
74
- const open = attrs ? `<${tag.tag} ${attrs}` : `<${tag.tag}`
75
- if (VOID_TAGS.has(tag.tag)) return `${open} />`
76
- const content = tag.children || ''
77
- // Escape sequences that could break out of script/style/noscript blocks:
78
- // 1. Closing tags like </script> — use Unicode escape in the slash
79
- // 2. HTML comment openers <!-- that could confuse parsers
80
- const body = content.replace(/<\/(script|style|noscript)/gi, '<\\/$1').replace(/<!--/g, '<\\!--')
81
- return `${open}>${body}</${tag.tag}>`
82
- }
83
-
84
- const ESC_RE = /[&<>"]/g
85
- const ESC_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
86
-
87
- function esc(s: string): string {
88
- return ESC_RE.test(s) ? s.replace(ESC_RE, (ch) => ESC_MAP[ch] as string) : s
89
- }
@@ -1,126 +0,0 @@
1
- /**
2
- * Bundle-level regression: `HeadContext` is constructed in EXACTLY ONE
3
- * place across the published `lib/` artifacts.
4
- *
5
- * The bug this test exists to catch: `@pyreon/head@0.21.0` shipped four
6
- * sub-entries (`lib/{index,provider,use-head,ssr}.js`) and the shared
7
- * `@vitus-labs/tools-rolldown` (< 2.4.0) invoked rolldown ONCE PER
8
- * SUB-ENTRY (no cross-entry shared chunks). Result: every sub-bundle
9
- * independently inlined `context.ts` and ran its own `createContext(null)`
10
- * at module init — each call minted a unique `Symbol.for(...).id`, so a
11
- * `useContext(HeadContext)` lookup in one bundle (e.g. the app's
12
- * `useHead` from `lib/use-head.js`) silently MISSED a
13
- * `provide(HeadContext)` from another (e.g. `renderWithHead` from
14
- * `lib/ssr.js`). The bug was invisible in dev / source-mode tests because
15
- * Vite's `bun` condition resolves to a single shared `src/context.ts`
16
- * (ESM single-evaluation guarantee), but SSG output silently dropped
17
- * every `useHead()`-registered tag — bad for SEO, social scrapers,
18
- * accessibility, no-JS.
19
- *
20
- * The durable fix lives upstream in `@vitus-labs/tools-rolldown >= 2.4.0`:
21
- * the build tool now creates SHARED CHUNKS across sub-entries, so the
22
- * shared `context.ts` gets hoisted into a single chunk (`lib/context.js`)
23
- * that every other sub-entry imports via relative-path `./context.js`.
24
- * `createContext(null)` runs exactly once at runtime; `HeadContext` is
25
- * one Symbol across every sub-entry's bundle. No per-package
26
- * externalization / self-package-import workaround needed.
27
- *
28
- * Structural assertions (the BUG-CLASS-LOCK — same intent, cleaner shape):
29
- * 1. `lib/context.js` is the ONLY bundle that calls `createContext(`.
30
- * 2. EVERY other published JS file under `lib/` (including
31
- * `lib/_chunks/*.js` shared chunks the tool emits) has ZERO
32
- * `createContext` references — they all import `HeadContext` from
33
- * `./context.js`, sharing the single Symbol identity.
34
- *
35
- * Together these invariants make the bug class structurally impossible
36
- * to re-introduce silently — any future regression (e.g. downgrade of
37
- * the build tool below 2.4.0, or a build-config change that re-enables
38
- * per-entry inlining) flips one of the per-bundle counters and trips
39
- * the assertion. Bisect-verified by reverting the
40
- * `@vitus-labs/tools-rolldown` bump: every non-context sub-bundle gets
41
- * its own inlined `createContext(null)` call (2 occurrences each), and
42
- * the second assertion fails on the first non-context bundle.
43
- */
44
-
45
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
46
- import { resolve } from 'node:path'
47
- import { describe, expect, it } from 'vitest'
48
-
49
- const PKG_ROOT = resolve(__dirname, '..', '..')
50
- const LIB_DIR = resolve(PKG_ROOT, 'lib')
51
- const libExists = existsSync(resolve(LIB_DIR, 'index.js'))
52
-
53
- const read = (rel: string) => readFileSync(resolve(LIB_DIR, rel), 'utf8')
54
-
55
- /** Every published JS file under lib/ (incl. _chunks/), excluding source maps. */
56
- function publishedJsFiles(): string[] {
57
- const out: string[] = []
58
- for (const entry of readdirSync(LIB_DIR, { withFileTypes: true })) {
59
- if (entry.isFile() && entry.name.endsWith('.js')) out.push(entry.name)
60
- else if (entry.isDirectory() && entry.name === '_chunks') {
61
- for (const sub of readdirSync(resolve(LIB_DIR, entry.name))) {
62
- if (sub.endsWith('.js')) out.push(`_chunks/${sub}`)
63
- }
64
- }
65
- }
66
- return out
67
- }
68
-
69
- /**
70
- * The bundle gate runs only when `lib/` has been built — `bun install`'s
71
- * postinstall bootstrap rebuilds whenever sources are newer than lib, so
72
- * in a normal dev session lib is always present. CI installs run the
73
- * same bootstrap. The `skip` is a defensive escape so the suite doesn't
74
- * false-fail in a partial worktree state where the user manually
75
- * deleted lib/.
76
- */
77
- describe.skipIf(!libExists)(
78
- '@pyreon/head bundle-level HeadContext identity (regression for the SSG-Meta-dropped bug)',
79
- () => {
80
- // ── Invariant 1: ONE createContext call across all bundles ───────
81
- //
82
- // `lib/context.js` is the canonical single chunk. Every other
83
- // sub-bundle / shared chunk should import `HeadContext` from it,
84
- // NOT inline a fresh `createContext(null)` call.
85
-
86
- it('lib/context.js is the SINGLE bundle that calls createContext()', () => {
87
- const src = read('context.js')
88
- // 2 occurrences = the `import { createContext }` line + the actual
89
- // `createContext(null)` call at module init. This is the SOURCE OF
90
- // TRUTH for HeadContext's Symbol identity.
91
- expect(src.match(/createContext/g)?.length ?? 0).toBe(2)
92
- expect(src).toContain('createContext(null)')
93
- })
94
-
95
- // ── Invariant 2: ZERO createContext references in EVERY other JS ──
96
- //
97
- // Covers both the top-level sub-entries AND the `_chunks/*.js` files
98
- // the build tool now emits — any file that contains `createContext`
99
- // would be running it at module-init and minting its own Symbol.
100
-
101
- it('NO other lib/*.js (or lib/_chunks/*.js) calls createContext()', () => {
102
- const offenders: Array<{ file: string; count: number }> = []
103
- for (const rel of publishedJsFiles()) {
104
- if (rel === 'context.js') continue
105
- const count = read(rel).match(/createContext/g)?.length ?? 0
106
- if (count > 0) offenders.push({ file: rel, count })
107
- }
108
- expect(offenders).toEqual([])
109
- })
110
-
111
- // ── Invariant 3: the package.json wiring that enables it ─────────
112
- //
113
- // The `./context` sub-export gives `HeadContext` a stable public
114
- // address. Locking it here means a future revert immediately fails
115
- // the test.
116
-
117
- it('package.json declares the ./context sub-export', () => {
118
- const pkg = JSON.parse(read('../package.json')) as {
119
- exports: Record<string, { bun?: string; import?: string; types?: string }>
120
- }
121
- expect(pkg.exports['./context']).toBeDefined()
122
- expect(pkg.exports['./context']?.import).toBe('./lib/context.js')
123
- expect(pkg.exports['./context']?.bun).toBe('./src/context.ts')
124
- })
125
- },
126
- )
@@ -1,251 +0,0 @@
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
-
195
- // E12 — Speculation Rules. Kill-criterion #2: real Chromium must PARSE
196
- // and ACCEPT the emitted block. happy-dom can't validate this — only a
197
- // real browser runs the Speculation Rules parser and emits a console
198
- // error on a malformed block. We assert: (a) the script lands in <head>
199
- // with the exact type, (b) its body is valid JSON, (c) the browser
200
- // raises NO "speculation rules" parse error, (d) HTMLScriptElement
201
- // recognises the type. Whether Chromium then prefetches/prerenders is
202
- // browser-discretionary (heuristic + headless-flag dependent) and is
203
- // intentionally NOT asserted — the framework's contract is "emit a
204
- // correct, valid declarative hint", same as `<link rel=prefetch>`.
205
- it('emits a real <script type="speculationrules"> Chromium parses without error', () => {
206
- const specErrors: string[] = []
207
- const origErr = console.error
208
- console.error = (...a: unknown[]) => {
209
- const msg = a.map(String).join(' ')
210
- if (/speculation\s*rules/i.test(msg)) specErrors.push(msg)
211
- }
212
- try {
213
- const { unmount } = mountInBrowser(
214
- h(
215
- HeadProvider,
216
- null,
217
- h(Page, {
218
- setup: () =>
219
- useHead({
220
- speculationRules: {
221
- prefetch: [
222
- {
223
- source: 'document',
224
- where: { selector_matches: 'a[data-spec]' },
225
- eagerness: 'moderate',
226
- },
227
- ],
228
- prerender: [{ source: 'list', urls: ['/about'], eagerness: 'conservative' }],
229
- },
230
- }),
231
- }),
232
- ),
233
- )
234
- const el = document.head.querySelector<HTMLScriptElement>(
235
- 'script[type="speculationrules"]',
236
- )
237
- expect(el).not.toBeNull()
238
- // (b) body is valid JSON and round-trips.
239
- const parsed = JSON.parse(el?.textContent ?? '')
240
- expect(parsed.prerender[0].urls).toEqual(['/about'])
241
- expect(parsed.prefetch[0].where).toEqual({ selector_matches: 'a[data-spec]' })
242
- // (d) real HTMLScriptElement carries the exact type the spec requires.
243
- expect(el?.type).toBe('speculationrules')
244
- // (c) Chromium parsed it WITHOUT raising a speculation-rules error.
245
- expect(specErrors).toEqual([])
246
- unmount()
247
- } finally {
248
- console.error = origErr
249
- }
250
- })
251
- })