@pyreon/head 0.24.4 → 0.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -12
- package/src/context.ts +0 -282
- package/src/dom.ts +0 -147
- package/src/index.ts +0 -18
- package/src/manifest.ts +0 -153
- package/src/provider.ts +0 -65
- package/src/ssr.ts +0 -89
- package/src/tests/context-identity.test.ts +0 -126
- package/src/tests/head.browser.test.tsx +0 -251
- package/src/tests/head.test.ts +0 -1313
- package/src/tests/integration.test.tsx +0 -83
- package/src/tests/manifest-snapshot.test.ts +0 -34
- package/src/tests/native-marker.test.ts +0 -9
- package/src/tests/provider-inherits-context.test.tsx +0 -131
- package/src/tests/setup.ts +0 -3
- package/src/use-head.ts +0 -123
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> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
|
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
|
-
})
|