@ilha/router 0.11.3 → 0.11.5

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.
@@ -1,285 +0,0 @@
1
- //#region src/head.ts
2
- /**
3
- * Render-scoped head collection and serialization.
4
- *
5
- * Loaders and render-time `head()` calls push `HeadInput` entries into a
6
- * store scoped to the current render (`withHeadStore`); `serializeHead`
7
- * turns the collected entries into document-shell fragments.
8
- */
9
- const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
10
- /** Dev-mode check mirroring index.ts — used to surface loader error detail. */
11
- function isDevEnv() {
12
- return isBrowser ? false : (() => {
13
- try {
14
- return (process.env?.["NODE_ENV"] ?? "development") !== "production";
15
- } catch {
16
- return true;
17
- }
18
- })();
19
- }
20
- const ILHA_HEAD_ATTR = "data-ilha-head";
21
- const ILHA_ROUTER_HTML_ATTR = "data-ilha-router-html";
22
- const ILHA_ROUTER_BODY_ATTR = "data-ilha-router-body";
23
- /** Browser-only fallback; SSR uses AsyncLocalStorage (see `withHeadStore`). */
24
- let _browserHeadStore = null;
25
- let _headAls = null;
26
- let _headAlsInit = null;
27
- /** ESM dynamic import — Nitro/Vite SSR workers have no `require`. */
28
- async function getHeadAlsAsync() {
29
- if (_headAls) return _headAls;
30
- if (!_headAlsInit) _headAlsInit = import(
31
- /* @vite-ignore */
32
- "node:async_hooks"
33
- ).then(({ AsyncLocalStorage }) => {
34
- _headAls = new AsyncLocalStorage();
35
- return _headAls;
36
- });
37
- return _headAlsInit;
38
- }
39
- function activeHeadStore() {
40
- if (isBrowser) return _browserHeadStore;
41
- return _headAls?.getStore() ?? null;
42
- }
43
- /**
44
- * Contribute `<head>` data from inside an island's `.render()` body or a
45
- * layout. During SSR this collects into the active render window; on the
46
- * client, entries are collected when the router re-renders a route inside
47
- * `withHeadStore` and then applied to `document`. Prefer a loader's `ctx.head`
48
- * for data that depends on the request.
49
- */
50
- function head(input) {
51
- const store = activeHeadStore();
52
- if (!store) {
53
- if (!isBrowser) console.warn("[ilha-router] head() called outside an SSR render window — ignored.");
54
- return;
55
- }
56
- store.entries.push(input);
57
- }
58
- function cssEscapeAttr(value) {
59
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
60
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
61
- }
62
- function headManagedMetaSelector(tag) {
63
- if ("charset" in tag) return `meta[charset][${ILHA_HEAD_ATTR}]`;
64
- if ("name" in tag) return `meta[name="${cssEscapeAttr(tag.name)}"][${ILHA_HEAD_ATTR}]`;
65
- if ("property" in tag) return `meta[property="${cssEscapeAttr(tag.property)}"][${ILHA_HEAD_ATTR}]`;
66
- if ("http-equiv" in tag) return `meta[http-equiv="${cssEscapeAttr(tag["http-equiv"])}"][${ILHA_HEAD_ATTR}]`;
67
- return null;
68
- }
69
- function headManagedLinkSelector(tag) {
70
- if (tag.rel && tag.href) return `link[rel="${cssEscapeAttr(tag.rel)}"][href="${cssEscapeAttr(tag.href)}"][${ILHA_HEAD_ATTR}]`;
71
- return null;
72
- }
73
- /**
74
- * Apply merged head entries on client navigations. Updates `document.title` and
75
- * managed meta/link nodes (`data-ilha-head`). Script tags from HeadInput are
76
- * SSR-only and are not re-injected here. Removes managed tags from the previous
77
- * route that are not part of this navigation's set.
78
- */
79
- function applyHeadEntriesToDocument(entries) {
80
- if (!isBrowser) return;
81
- let title;
82
- let titleTemplate;
83
- const meta = [];
84
- const link = [];
85
- let htmlAttrs = {};
86
- let bodyAttrs = {};
87
- for (const entry of entries) {
88
- if (entry.title !== void 0) title = entry.title;
89
- if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
90
- if (entry.meta) meta.push(...entry.meta);
91
- if (entry.link) link.push(...entry.link);
92
- if (entry.htmlAttrs) htmlAttrs = {
93
- ...htmlAttrs,
94
- ...entry.htmlAttrs
95
- };
96
- if (entry.bodyAttrs) bodyAttrs = {
97
- ...bodyAttrs,
98
- ...entry.bodyAttrs
99
- };
100
- }
101
- const resolvedTitle = applyTitleTemplate(title, titleTemplate);
102
- if (resolvedTitle !== void 0) document.title = resolvedTitle;
103
- const metaTags = dedupByKey(meta, metaDedupKey);
104
- const linkTags = dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`);
105
- const keepManaged = /* @__PURE__ */ new Set();
106
- for (const tag of metaTags) {
107
- const selector = headManagedMetaSelector(tag);
108
- if (!selector) continue;
109
- let el = document.querySelector(selector);
110
- if (!el) {
111
- el = document.createElement("meta");
112
- el.setAttribute(ILHA_HEAD_ATTR, "");
113
- document.head.appendChild(el);
114
- }
115
- for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
116
- keepManaged.add(el);
117
- }
118
- for (const tag of linkTags) {
119
- const selector = headManagedLinkSelector(tag);
120
- let el = selector ? document.querySelector(selector) : null;
121
- if (!el) {
122
- el = document.createElement("link");
123
- el.setAttribute(ILHA_HEAD_ATTR, "");
124
- document.head.appendChild(el);
125
- }
126
- for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
127
- keepManaged.add(el);
128
- }
129
- for (const el of document.head.querySelectorAll(`[${ILHA_HEAD_ATTR}]`)) if (!keepManaged.has(el)) el.remove();
130
- const htmlEl = document.documentElement;
131
- const prevHtmlKeys = (htmlEl.getAttribute(ILHA_ROUTER_HTML_ATTR) ?? "").split(/\s+/).filter(Boolean);
132
- for (const k of prevHtmlKeys) htmlEl.removeAttribute(k);
133
- const nextHtmlKeys = Object.keys(htmlAttrs);
134
- for (const [k, v] of Object.entries(htmlAttrs)) htmlEl.setAttribute(k, v);
135
- if (nextHtmlKeys.length) htmlEl.setAttribute(ILHA_ROUTER_HTML_ATTR, nextHtmlKeys.join(" "));
136
- else htmlEl.removeAttribute(ILHA_ROUTER_HTML_ATTR);
137
- const bodyEl = document.body;
138
- const prevBodyKeys = (bodyEl.getAttribute(ILHA_ROUTER_BODY_ATTR) ?? "").split(/\s+/).filter(Boolean);
139
- for (const k of prevBodyKeys) bodyEl.removeAttribute(k);
140
- const nextBodyKeys = Object.keys(bodyAttrs);
141
- for (const [k, v] of Object.entries(bodyAttrs)) bodyEl.setAttribute(k, v);
142
- if (nextBodyKeys.length) bodyEl.setAttribute(ILHA_ROUTER_BODY_ATTR, nextBodyKeys.join(" "));
143
- else bodyEl.removeAttribute(ILHA_ROUTER_BODY_ATTR);
144
- }
145
- async function withHeadStore(store, fn) {
146
- if (isBrowser) {
147
- const prev = _browserHeadStore;
148
- _browserHeadStore = store;
149
- try {
150
- return await fn();
151
- } finally {
152
- _browserHeadStore = prev;
153
- }
154
- }
155
- return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
156
- }
157
- const HEAD_ESC = {
158
- "&": "&amp;",
159
- "<": "&lt;",
160
- ">": "&gt;",
161
- "\"": "&quot;",
162
- "'": "&#39;"
163
- };
164
- function escapeHeadAttr(value) {
165
- return String(value).replace(/[&<>"']/g, (c) => HEAD_ESC[c]);
166
- }
167
- function serializeAttrs(attrs) {
168
- const parts = [];
169
- for (const [k, v] of Object.entries(attrs)) {
170
- if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(k) || /^on[a-z]/i.test(k)) {
171
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe head attribute "${k}".`);
172
- continue;
173
- }
174
- parts.push(` ${k}="${escapeHeadAttr(v)}"`);
175
- }
176
- return parts.join("");
177
- }
178
- function isSafeUrl(value) {
179
- const v = value.trim();
180
- if (v === "") return true;
181
- if (/[\\\u0000-\u0020]/.test(v)) return false;
182
- if (v.startsWith("//")) return false;
183
- if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./")) return true;
184
- if (/^(?:javascript|vbscript|data):/i.test(v)) return false;
185
- try {
186
- const u = new URL(v, "http://localhost");
187
- return u.protocol === "http:" || u.protocol === "https:";
188
- } catch {
189
- return false;
190
- }
191
- }
192
- function metaDedupKey(tag) {
193
- if ("charset" in tag) return "charset";
194
- if ("name" in tag) return `name:${tag.name}`;
195
- if ("property" in tag) return `property:${tag.property}`;
196
- if ("http-equiv" in tag) return `http-equiv:${tag["http-equiv"]}`;
197
- return JSON.stringify(tag);
198
- }
199
- function dedupByKey(tags, keyOf) {
200
- const map = /* @__PURE__ */ new Map();
201
- for (const tag of tags) map.set(keyOf(tag), tag);
202
- return [...map.values()];
203
- }
204
- function applyTitleTemplate(title, template) {
205
- if (template === void 0) return title;
206
- if (typeof template === "function") return template(title);
207
- return template.replace(/%s/g, title ?? "");
208
- }
209
- /**
210
- * Merge head entries in contribution order (loader first as the base, then
211
- * render-time outer→inner layouts, then the page) and serialize. Later entries
212
- * win on collision; the last `titleTemplate` wraps the resolved title.
213
- */
214
- function serializeHead(entries) {
215
- let title;
216
- let titleTemplate;
217
- const meta = [];
218
- const link = [];
219
- const script = [];
220
- let htmlAttrs = {};
221
- let bodyAttrs = {};
222
- for (const entry of entries) {
223
- if (entry.title !== void 0) title = entry.title;
224
- if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
225
- if (entry.meta) meta.push(...entry.meta);
226
- if (entry.link) link.push(...entry.link);
227
- if (entry.script) script.push(...entry.script);
228
- if (entry.htmlAttrs) htmlAttrs = {
229
- ...htmlAttrs,
230
- ...entry.htmlAttrs
231
- };
232
- if (entry.bodyAttrs) bodyAttrs = {
233
- ...bodyAttrs,
234
- ...entry.bodyAttrs
235
- };
236
- }
237
- const resolvedTitle = applyTitleTemplate(title, titleTemplate);
238
- const parts = [];
239
- if (resolvedTitle !== void 0) parts.push(`<title>${escapeHeadAttr(resolvedTitle)}</title>`);
240
- for (const tag of dedupByKey(meta, metaDedupKey)) {
241
- if (/^refresh$/i.test(tag["http-equiv"] ?? "") && !isSafeRefreshTarget(tag.content ?? "")) {
242
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe meta refresh target "${tag.content}".`);
243
- continue;
244
- }
245
- parts.push(`<meta${serializeAttrs({
246
- ...tag,
247
- [ILHA_HEAD_ATTR]: ""
248
- })} />`);
249
- }
250
- for (const tag of dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`)) {
251
- if (tag.href !== void 0 && !isSafeUrl(tag.href)) {
252
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe link href "${tag.href}".`);
253
- continue;
254
- }
255
- parts.push(`<link${serializeAttrs({
256
- ...tag,
257
- [ILHA_HEAD_ATTR]: ""
258
- })} />`);
259
- }
260
- for (const tag of script) {
261
- if (tag.src !== void 0 && !isSafeUrl(tag.src)) {
262
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe script src "${tag.src}".`);
263
- continue;
264
- }
265
- const { children, ...attrs } = tag;
266
- const body = (children ?? "").replace(/<\/script/gi, "<\\/script");
267
- parts.push(`<script${serializeAttrs(attrs)}>${body}<\/script>`);
268
- }
269
- return {
270
- headTags: parts.join("\n "),
271
- htmlAttrs: serializeAttrs(htmlAttrs),
272
- bodyAttrs: serializeAttrs(bodyAttrs)
273
- };
274
- }
275
- function isSafeRefreshTarget(content) {
276
- const match = /url\s*=\s*(['"]?)([^'";\s]+)\1/i.exec(content);
277
- if (!match) return true;
278
- const target = match[2] ?? "";
279
- if (/[\\\u0000-\u0020]/.test(target)) return false;
280
- if (target.startsWith("//")) return false;
281
- return target.startsWith("/") || target.startsWith("./");
282
- }
283
-
284
- //#endregion
285
- export { withHeadStore as i, head as n, serializeHead as r, applyHeadEntriesToDocument as t };