@pyreon/head 0.21.0 → 0.23.0
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 +146 -28
- package/lib/_chunks/use-head-B8n30QMl.js +214 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/context.js +62 -0
- package/lib/index.js +4 -317
- package/lib/provider.js +2 -59
- package/lib/ssr.js +2 -59
- package/lib/types/context.d.ts +205 -0
- package/lib/use-head.js +3 -216
- package/package.json +12 -7
- package/src/tests/context-identity.test.ts +126 -0
- package/lib/analysis/provider.js.html +0 -5406
- package/lib/analysis/ssr.js.html +0 -5406
- package/lib/analysis/use-head.js.html +0 -5406
package/lib/use-head.js
CHANGED
|
@@ -1,217 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import "./context.js";
|
|
2
|
+
import { t as useHead } from "./_chunks/use-head-B8n30QMl.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
const HeadContext = createContext(null);
|
|
6
|
-
|
|
7
|
-
//#endregion
|
|
8
|
-
//#region src/dom.ts
|
|
9
|
-
const ATTR = "data-pyreon-head";
|
|
10
|
-
/** Tracks managed elements by key — avoids querySelectorAll on every sync */
|
|
11
|
-
const managedElements = /* @__PURE__ */ new Map();
|
|
12
|
-
/**
|
|
13
|
-
* Sync the resolved head tags to the real DOM <head>.
|
|
14
|
-
* Uses incremental diffing: matches existing elements by key, patches attributes
|
|
15
|
-
* in-place, adds new elements, and removes stale ones.
|
|
16
|
-
* Also syncs htmlAttrs, bodyAttrs, and applies titleTemplate.
|
|
17
|
-
* No-op on the server (typeof document === "undefined").
|
|
18
|
-
*/
|
|
19
|
-
function patchExistingTag(found, tag, kept) {
|
|
20
|
-
kept.add(found.getAttribute(ATTR));
|
|
21
|
-
patchAttrs(found, tag.props);
|
|
22
|
-
const content = String(tag.children);
|
|
23
|
-
if (found.textContent !== content) found.textContent = content;
|
|
24
|
-
}
|
|
25
|
-
function createNewTag(tag) {
|
|
26
|
-
if (typeof document === "undefined") return;
|
|
27
|
-
const el = document.createElement(tag.tag);
|
|
28
|
-
const key = tag.key;
|
|
29
|
-
el.setAttribute(ATTR, key);
|
|
30
|
-
for (const [k, v] of Object.entries(tag.props)) el.setAttribute(k, v);
|
|
31
|
-
if (tag.children) el.textContent = tag.children;
|
|
32
|
-
document.head.appendChild(el);
|
|
33
|
-
managedElements.set(key, el);
|
|
34
|
-
}
|
|
35
|
-
function syncDom(ctx) {
|
|
36
|
-
if (typeof document === "undefined") return;
|
|
37
|
-
const tags = ctx.resolve();
|
|
38
|
-
const titleTemplate = ctx.resolveTitleTemplate();
|
|
39
|
-
let needsSeed = managedElements.size === 0;
|
|
40
|
-
if (!needsSeed) {
|
|
41
|
-
const sample = managedElements.values().next().value;
|
|
42
|
-
if (sample && !sample.isConnected) {
|
|
43
|
-
managedElements.clear();
|
|
44
|
-
needsSeed = true;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
if (needsSeed) {
|
|
48
|
-
const existing = document.head.querySelectorAll(`[${ATTR}]`);
|
|
49
|
-
for (const el of existing) managedElements.set(el.getAttribute(ATTR), el);
|
|
50
|
-
}
|
|
51
|
-
const kept = /* @__PURE__ */ new Set();
|
|
52
|
-
for (const tag of tags) {
|
|
53
|
-
if (tag.tag === "title") {
|
|
54
|
-
document.title = applyTitleTemplate(String(tag.children), titleTemplate);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
const key = tag.key;
|
|
58
|
-
const found = managedElements.get(key);
|
|
59
|
-
if (found && found.tagName.toLowerCase() === tag.tag) patchExistingTag(found, tag, kept);
|
|
60
|
-
else {
|
|
61
|
-
if (found) {
|
|
62
|
-
found.remove();
|
|
63
|
-
managedElements.delete(key);
|
|
64
|
-
}
|
|
65
|
-
createNewTag(tag);
|
|
66
|
-
kept.add(key);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
for (const [key, el] of managedElements) if (!kept.has(key)) {
|
|
70
|
-
el.remove();
|
|
71
|
-
managedElements.delete(key);
|
|
72
|
-
}
|
|
73
|
-
syncElementAttrs(document.documentElement, ctx.resolveHtmlAttrs());
|
|
74
|
-
syncElementAttrs(document.body, ctx.resolveBodyAttrs());
|
|
75
|
-
}
|
|
76
|
-
/** Patch an element's attributes to match the desired props. */
|
|
77
|
-
function patchAttrs(el, props) {
|
|
78
|
-
for (let i = el.attributes.length - 1; i >= 0; i--) {
|
|
79
|
-
const attr = el.attributes[i];
|
|
80
|
-
if (!attr || attr.name === ATTR) continue;
|
|
81
|
-
if (!(attr.name in props)) el.removeAttribute(attr.name);
|
|
82
|
-
}
|
|
83
|
-
for (const [k, v] of Object.entries(props)) if (el.getAttribute(k) !== v) el.setAttribute(k, v);
|
|
84
|
-
}
|
|
85
|
-
function applyTitleTemplate(title, template) {
|
|
86
|
-
if (!template) return title;
|
|
87
|
-
if (typeof template === "function") return template(title);
|
|
88
|
-
return template.replace(/%s/g, title);
|
|
89
|
-
}
|
|
90
|
-
/** Sync pyreon-managed attributes on <html> or <body>. */
|
|
91
|
-
function syncElementAttrs(el, attrs) {
|
|
92
|
-
const managed = el.getAttribute(`${ATTR}-attrs`);
|
|
93
|
-
if (managed) {
|
|
94
|
-
for (const name of managed.split(",")) if (name && !(name in attrs)) el.removeAttribute(name);
|
|
95
|
-
}
|
|
96
|
-
const keys = [];
|
|
97
|
-
for (const [k, v] of Object.entries(attrs)) {
|
|
98
|
-
keys.push(k);
|
|
99
|
-
if (el.getAttribute(k) !== v) el.setAttribute(k, v);
|
|
100
|
-
}
|
|
101
|
-
if (keys.length > 0) el.setAttribute(`${ATTR}-attrs`, keys.join(","));
|
|
102
|
-
else if (managed) el.removeAttribute(`${ATTR}-attrs`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
//#endregion
|
|
106
|
-
//#region src/use-head.ts
|
|
107
|
-
/** Cast a strict tag interface to the internal props format, stripping undefined values */
|
|
108
|
-
function toProps(obj) {
|
|
109
|
-
const result = {};
|
|
110
|
-
for (const [k, v] of Object.entries(obj)) if (v !== void 0) result[k] = v;
|
|
111
|
-
return result;
|
|
112
|
-
}
|
|
113
|
-
function buildEntry(o) {
|
|
114
|
-
const tags = [];
|
|
115
|
-
if (o.title != null) tags.push({
|
|
116
|
-
tag: "title",
|
|
117
|
-
key: "title",
|
|
118
|
-
children: o.title
|
|
119
|
-
});
|
|
120
|
-
o.meta?.forEach((m, i) => {
|
|
121
|
-
tags.push({
|
|
122
|
-
tag: "meta",
|
|
123
|
-
key: m.name ?? m.property ?? `meta-${i}`,
|
|
124
|
-
props: toProps(m)
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
o.link?.forEach((l, i) => {
|
|
128
|
-
tags.push({
|
|
129
|
-
tag: "link",
|
|
130
|
-
key: l.href ? `link-${l.rel || ""}-${l.href}` : l.rel ? `link-${l.rel}` : `link-${i}`,
|
|
131
|
-
props: toProps(l)
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
o.script?.forEach((s, i) => {
|
|
135
|
-
const { children, ...rest } = s;
|
|
136
|
-
tags.push({
|
|
137
|
-
tag: "script",
|
|
138
|
-
key: s.src ?? `script-${i}`,
|
|
139
|
-
props: toProps(rest),
|
|
140
|
-
...children != null ? { children } : {}
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
o.style?.forEach((s, i) => {
|
|
144
|
-
const { children, ...rest } = s;
|
|
145
|
-
tags.push({
|
|
146
|
-
tag: "style",
|
|
147
|
-
key: `style-${i}`,
|
|
148
|
-
props: toProps(rest),
|
|
149
|
-
children
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
o.noscript?.forEach((ns, i) => {
|
|
153
|
-
tags.push({
|
|
154
|
-
tag: "noscript",
|
|
155
|
-
key: `noscript-${i}`,
|
|
156
|
-
children: ns.children
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
if (o.jsonLd) tags.push({
|
|
160
|
-
tag: "script",
|
|
161
|
-
key: "jsonld",
|
|
162
|
-
props: { type: "application/ld+json" },
|
|
163
|
-
children: JSON.stringify(o.jsonLd)
|
|
164
|
-
});
|
|
165
|
-
if (o.speculationRules) tags.push({
|
|
166
|
-
tag: "script",
|
|
167
|
-
key: "speculationrules",
|
|
168
|
-
props: { type: "speculationrules" },
|
|
169
|
-
children: JSON.stringify(o.speculationRules)
|
|
170
|
-
});
|
|
171
|
-
if (o.base) tags.push({
|
|
172
|
-
tag: "base",
|
|
173
|
-
key: "base",
|
|
174
|
-
props: toProps(o.base)
|
|
175
|
-
});
|
|
176
|
-
return {
|
|
177
|
-
tags,
|
|
178
|
-
titleTemplate: o.titleTemplate,
|
|
179
|
-
htmlAttrs: o.htmlAttrs,
|
|
180
|
-
bodyAttrs: o.bodyAttrs
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Register head tags (title, meta, link, script, style, noscript, base, jsonLd)
|
|
185
|
-
* for the current component.
|
|
186
|
-
*
|
|
187
|
-
* Accepts a static object or a reactive getter:
|
|
188
|
-
* useHead({ title: "My Page", meta: [{ name: "description", content: "..." }] })
|
|
189
|
-
* useHead(() => ({ title: `${count()} items` })) // updates when signal changes
|
|
190
|
-
*
|
|
191
|
-
* Tags are deduplicated by key — innermost component wins.
|
|
192
|
-
* Requires a <HeadProvider> (CSR) or renderWithHead() (SSR) ancestor.
|
|
193
|
-
*/
|
|
194
|
-
function useHead(input) {
|
|
195
|
-
const ctx = useContext(HeadContext);
|
|
196
|
-
if (!ctx) return;
|
|
197
|
-
const id = Symbol();
|
|
198
|
-
if (typeof input === "function") if (typeof document !== "undefined") effect(() => {
|
|
199
|
-
ctx.add(id, buildEntry(input()));
|
|
200
|
-
syncDom(ctx);
|
|
201
|
-
});
|
|
202
|
-
else ctx.add(id, buildEntry(input()));
|
|
203
|
-
else {
|
|
204
|
-
ctx.add(id, buildEntry(input));
|
|
205
|
-
onMount(() => {
|
|
206
|
-
syncDom(ctx);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
onUnmount(() => {
|
|
210
|
-
ctx.remove(id);
|
|
211
|
-
syncDom(ctx);
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
//#endregion
|
|
216
|
-
export { useHead };
|
|
217
|
-
//# sourceMappingURL=use-head.js.map
|
|
4
|
+
export { useHead };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/head",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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": {
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
"import": "./lib/index.js",
|
|
31
31
|
"types": "./lib/types/index.d.ts"
|
|
32
32
|
},
|
|
33
|
+
"./context": {
|
|
34
|
+
"bun": "./src/context.ts",
|
|
35
|
+
"import": "./lib/context.js",
|
|
36
|
+
"types": "./lib/types/context.d.ts"
|
|
37
|
+
},
|
|
33
38
|
"./provider": {
|
|
34
39
|
"bun": "./src/provider.ts",
|
|
35
40
|
"import": "./lib/provider.js",
|
|
@@ -59,16 +64,16 @@
|
|
|
59
64
|
"prepublishOnly": "bun run build"
|
|
60
65
|
},
|
|
61
66
|
"dependencies": {
|
|
62
|
-
"@pyreon/core": "^0.
|
|
63
|
-
"@pyreon/reactivity": "^0.
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
67
|
+
"@pyreon/core": "^0.23.0",
|
|
68
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
69
|
+
"@pyreon/runtime-server": "^0.23.0"
|
|
65
70
|
},
|
|
66
71
|
"devDependencies": {
|
|
67
72
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
68
73
|
"@pyreon/manifest": "0.13.1",
|
|
69
|
-
"@pyreon/runtime-dom": "^0.
|
|
70
|
-
"@pyreon/runtime-server": "^0.
|
|
71
|
-
"@pyreon/test-utils": "^0.13.
|
|
74
|
+
"@pyreon/runtime-dom": "^0.23.0",
|
|
75
|
+
"@pyreon/runtime-server": "^0.23.0",
|
|
76
|
+
"@pyreon/test-utils": "^0.13.10",
|
|
72
77
|
"@vitest/browser-playwright": "^4.1.4"
|
|
73
78
|
},
|
|
74
79
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
)
|