@praxisjs/head 0.3.0 → 0.3.2
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/CHANGELOG.md +40 -0
- package/dist/head-stack.d.ts.map +1 -1
- package/dist/head-stack.js +114 -94
- package/dist/head-stack.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +15 -6
- package/src/head-stack.ts +111 -86
- package/dist/__tests__/head.test.d.ts +0 -2
- package/dist/__tests__/head.test.d.ts.map +0 -1
- package/dist/__tests__/head.test.js +0 -481
- package/dist/__tests__/head.test.js.map +0 -1
- package/dist/__tests__/internal.test.d.ts +0 -2
- package/dist/__tests__/internal.test.d.ts.map +0 -1
- package/dist/__tests__/internal.test.js +0 -32
- package/dist/__tests__/internal.test.js.map +0 -1
- package/src/__tests__/head.test.ts +0 -381
- package/src/__tests__/internal.test.ts +0 -38
- package/tsconfig.json +0 -8
package/src/head-stack.ts
CHANGED
|
@@ -49,19 +49,32 @@ interface HeadEntry {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const _stack: HeadEntry[] = [];
|
|
52
|
+
let _managed = new Map<string, HTMLElement>();
|
|
52
53
|
let _initialTitle: string | undefined;
|
|
54
|
+
// Tags a prerender left in the document are not in `_managed`; they are swept once, on the first
|
|
55
|
+
// sync, instead of querying the whole <head> on every update.
|
|
56
|
+
let _swept = false;
|
|
53
57
|
|
|
54
58
|
/** Increments on every head update. Read reactively to subscribe to head changes. */
|
|
55
59
|
export const headVersion = signal(0);
|
|
56
60
|
|
|
61
|
+
function _bump(): void {
|
|
62
|
+
headVersion.update((n) => n + 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
export function pushHead(id: symbol, config: HeadConfig): void {
|
|
58
66
|
if (typeof document === "undefined") return;
|
|
59
67
|
_initialTitle ??= document.title;
|
|
60
68
|
const idx = _stack.findIndex((e) => e.id === id);
|
|
61
|
-
if (idx
|
|
62
|
-
_stack[idx].config = config;
|
|
63
|
-
} else {
|
|
69
|
+
if (idx === -1) {
|
|
64
70
|
_stack.push({ id, config });
|
|
71
|
+
} else {
|
|
72
|
+
_stack[idx].config = config;
|
|
73
|
+
// Only the top entry is shown; one below it changes nothing in the document yet.
|
|
74
|
+
if (idx !== _stack.length - 1) {
|
|
75
|
+
_bump();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
65
78
|
}
|
|
66
79
|
_apply();
|
|
67
80
|
}
|
|
@@ -69,109 +82,119 @@ export function pushHead(id: symbol, config: HeadConfig): void {
|
|
|
69
82
|
export function removeHead(id: symbol): void {
|
|
70
83
|
if (typeof document === "undefined") return;
|
|
71
84
|
const idx = _stack.findIndex((e) => e.id === id);
|
|
72
|
-
if (idx
|
|
73
|
-
|
|
85
|
+
if (idx === -1) return;
|
|
86
|
+
const wasTop = idx === _stack.length - 1;
|
|
87
|
+
_stack.splice(idx, 1);
|
|
88
|
+
if (wasTop) _apply();
|
|
89
|
+
else _bump();
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (top === undefined) {
|
|
82
|
-
document.title = _initialTitle ?? "";
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
92
|
+
interface TagSpec {
|
|
93
|
+
tag: "meta" | "link";
|
|
94
|
+
/** Attribute names and values, alternating. */
|
|
95
|
+
attrs: string[];
|
|
96
|
+
}
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
function _meta(specs: TagSpec[], attr: "name" | "property", key: string, content: string | undefined): void {
|
|
99
|
+
if (content != null) specs.push({ tag: "meta", attrs: [attr, key, "content", content] });
|
|
100
|
+
}
|
|
87
101
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (config.canonical != null) _canonical(config.canonical);
|
|
91
|
-
for (const link of config.preload ?? []) _linkPreload(link);
|
|
92
|
-
for (const link of config.prefetch ?? []) _linkPrefetch(link);
|
|
102
|
+
function _collect(config: HeadConfig): TagSpec[] {
|
|
103
|
+
const specs: TagSpec[] = [];
|
|
93
104
|
|
|
105
|
+
_meta(specs, "name", "description", config.description);
|
|
106
|
+
if (config.canonical != null) {
|
|
107
|
+
specs.push({ tag: "link", attrs: ["rel", "canonical", "href", config.canonical] });
|
|
108
|
+
}
|
|
109
|
+
for (const link of config.preload ?? []) {
|
|
110
|
+
const attrs = ["rel", "preload", "href", link.href, "as", link.as];
|
|
111
|
+
if (link.type != null) attrs.push("type", link.type);
|
|
112
|
+
if (link.crossOrigin != null) attrs.push("crossorigin", link.crossOrigin);
|
|
113
|
+
specs.push({ tag: "link", attrs });
|
|
114
|
+
}
|
|
115
|
+
for (const link of config.prefetch ?? []) {
|
|
116
|
+
const attrs = ["rel", "prefetch", "href", link.href];
|
|
117
|
+
if (link.as != null) attrs.push("as", link.as);
|
|
118
|
+
specs.push({ tag: "link", attrs });
|
|
119
|
+
}
|
|
94
120
|
for (const tag of config.meta ?? []) {
|
|
95
|
-
if (tag.property != null)
|
|
96
|
-
else if (tag.name != null)
|
|
121
|
+
if (tag.property != null) _meta(specs, "property", tag.property, tag.content);
|
|
122
|
+
else if (tag.name != null) _meta(specs, "name", tag.name, tag.content);
|
|
97
123
|
}
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"og:site_name": og.siteName,
|
|
108
|
-
});
|
|
125
|
+
const og = config.og;
|
|
126
|
+
if (og != null) {
|
|
127
|
+
_meta(specs, "property", "og:title", og.title);
|
|
128
|
+
_meta(specs, "property", "og:description", og.description);
|
|
129
|
+
_meta(specs, "property", "og:image", og.image);
|
|
130
|
+
_meta(specs, "property", "og:url", og.url);
|
|
131
|
+
_meta(specs, "property", "og:type", og.type);
|
|
132
|
+
_meta(specs, "property", "og:site_name", og.siteName);
|
|
109
133
|
}
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"twitter:description": tw.description,
|
|
117
|
-
"twitter:image": tw.image,
|
|
118
|
-
});
|
|
134
|
+
const tw = config.twitter;
|
|
135
|
+
if (tw != null) {
|
|
136
|
+
_meta(specs, "name", "twitter:card", tw.card);
|
|
137
|
+
_meta(specs, "name", "twitter:title", tw.title);
|
|
138
|
+
_meta(specs, "name", "twitter:description", tw.description);
|
|
139
|
+
_meta(specs, "name", "twitter:image", tw.image);
|
|
119
140
|
}
|
|
141
|
+
return specs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _create(spec: TagSpec): HTMLElement {
|
|
145
|
+
const el = document.createElement(spec.tag);
|
|
146
|
+
const { attrs } = spec;
|
|
147
|
+
for (let i = 0; i < attrs.length; i += 2) el.setAttribute(attrs[i], attrs[i + 1]);
|
|
148
|
+
el.setAttribute(ATTR, "");
|
|
149
|
+
return el;
|
|
120
150
|
}
|
|
121
151
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
// Tags the new config shares with the previous one stay in the document untouched instead of being
|
|
153
|
+
// removed and recreated on every update; only the difference is added or removed.
|
|
154
|
+
function _syncTags(specs: TagSpec[]): void {
|
|
155
|
+
const next = new Map<string, HTMLElement>();
|
|
156
|
+
|
|
157
|
+
for (const spec of specs) {
|
|
158
|
+
const base = `${spec.tag}\0${spec.attrs.join("\0")}`;
|
|
159
|
+
let key = base;
|
|
160
|
+
for (let n = 2; next.has(key); n++) key = `${base}#${String(n)}`;
|
|
161
|
+
|
|
162
|
+
let el = _managed.get(key);
|
|
163
|
+
if (el?.isConnected) {
|
|
164
|
+
_managed.delete(key);
|
|
165
|
+
} else {
|
|
166
|
+
el = _create(spec);
|
|
167
|
+
document.head.appendChild(el);
|
|
129
168
|
}
|
|
169
|
+
next.set(key, el);
|
|
130
170
|
}
|
|
131
|
-
}
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
const el = document.createElement("meta");
|
|
135
|
-
el.name = name;
|
|
136
|
-
el.content = content;
|
|
137
|
-
el.setAttribute(ATTR, "");
|
|
138
|
-
document.head.appendChild(el);
|
|
139
|
-
}
|
|
172
|
+
for (const stale of _managed.values()) stale.remove();
|
|
140
173
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
if (!_swept) {
|
|
175
|
+
_swept = true;
|
|
176
|
+
const keep = new Set<Element>(next.values());
|
|
177
|
+
document.head.querySelectorAll(`[${ATTR}]`).forEach((el) => {
|
|
178
|
+
if (!keep.has(el)) el.remove();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
_managed = next;
|
|
147
182
|
}
|
|
148
183
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
el.rel = "canonical";
|
|
152
|
-
el.href = href;
|
|
153
|
-
el.setAttribute(ATTR, "");
|
|
154
|
-
document.head.appendChild(el);
|
|
155
|
-
}
|
|
184
|
+
function _apply(): void {
|
|
185
|
+
_bump();
|
|
156
186
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
el.setAttribute(ATTR, "");
|
|
165
|
-
document.head.appendChild(el);
|
|
166
|
-
}
|
|
187
|
+
const top = _stack.at(-1);
|
|
188
|
+
if (top === undefined) {
|
|
189
|
+
_syncTags([]);
|
|
190
|
+
// Set by the push that the removal emptying the stack undid.
|
|
191
|
+
document.title = _initialTitle as string;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
167
194
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
el.href = link.href;
|
|
172
|
-
if (link.as != null) el.setAttribute("as", link.as);
|
|
173
|
-
el.setAttribute(ATTR, "");
|
|
174
|
-
document.head.appendChild(el);
|
|
195
|
+
const { config } = top;
|
|
196
|
+
if (config.title != null) document.title = config.title;
|
|
197
|
+
_syncTags(_collect(config));
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/**
|
|
@@ -182,7 +205,9 @@ function _linkPrefetch(link: LinkPrefetch): void {
|
|
|
182
205
|
*/
|
|
183
206
|
export function resetHeadState(): void {
|
|
184
207
|
_stack.length = 0;
|
|
208
|
+
_managed = new Map();
|
|
185
209
|
_initialTitle = undefined;
|
|
210
|
+
_swept = false;
|
|
186
211
|
if (typeof document !== "undefined") {
|
|
187
212
|
document.querySelectorAll(`[${ATTR}]`).forEach((el) => { el.remove(); });
|
|
188
213
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"head.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/head.test.ts"],"names":[],"mappings":""}
|