@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/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 >= 0) {
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 >= 0) _stack.splice(idx, 1);
73
- _apply();
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
- function _apply(): void {
77
- headVersion.update((n) => n + 1);
78
- document.querySelectorAll(`[${ATTR}]`).forEach((el) => { el.remove(); });
79
-
80
- const top = _stack.at(-1);
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
- const { config } = top;
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
- if (config.title != null) document.title = config.title;
89
- if (config.description != null) _metaName("description", config.description);
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) _metaProp(tag.property, tag.content);
96
- else if (tag.name != null) _metaName(tag.name, tag.content);
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
- if (config.og != null) {
100
- const og = config.og;
101
- _applyMap("property", {
102
- "og:title": og.title,
103
- "og:description": og.description,
104
- "og:image": og.image,
105
- "og:url": og.url,
106
- "og:type": og.type,
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 (config.twitter != null) {
112
- const tw = config.twitter;
113
- _applyMap("name", {
114
- "twitter:card": tw.card,
115
- "twitter:title": tw.title,
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
- function _applyMap(
123
- attr: "name" | "property",
124
- map: Record<string, string | undefined>,
125
- ): void {
126
- for (const [key, value] of Object.entries(map)) {
127
- if (value != null) {
128
- if (attr === "name") { _metaName(key, value); } else { _metaProp(key, value); }
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
- function _metaName(name: string, content: string): void {
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
- function _metaProp(property: string, content: string): void {
142
- const el = document.createElement("meta");
143
- el.setAttribute("property", property);
144
- el.content = content;
145
- el.setAttribute(ATTR, "");
146
- document.head.appendChild(el);
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 _canonical(href: string): void {
150
- const el = document.createElement("link");
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
- function _linkPreload(link: LinkPreload): void {
158
- const el = document.createElement("link");
159
- el.rel = "preload";
160
- el.href = link.href;
161
- el.setAttribute("as", link.as);
162
- if (link.type != null) el.type = link.type;
163
- if (link.crossOrigin != null) el.crossOrigin = link.crossOrigin;
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
- function _linkPrefetch(link: LinkPrefetch): void {
169
- const el = document.createElement("link");
170
- el.rel = "prefetch";
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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=head.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"head.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/head.test.ts"],"names":[],"mappings":""}