@ilha/router 0.9.2 → 0.10.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.
@@ -0,0 +1,337 @@
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("node:async_hooks").then(({ AsyncLocalStorage }) => {
31
+ _headAls = new AsyncLocalStorage();
32
+ return _headAls;
33
+ });
34
+ return _headAlsInit;
35
+ }
36
+ function activeHeadStore() {
37
+ if (isBrowser) return _browserHeadStore;
38
+ return _headAls?.getStore() ?? null;
39
+ }
40
+ /**
41
+ * Contribute `<head>` data from inside an island's `.render()` body or a
42
+ * layout. During SSR this collects into the active render window; on the
43
+ * client, entries are collected when the router re-renders a route inside
44
+ * `withHeadStore` and then applied to `document`. Prefer a loader's `ctx.head`
45
+ * for data that depends on the request.
46
+ */
47
+ function head(input) {
48
+ const store = activeHeadStore();
49
+ if (!store) {
50
+ if (!isBrowser) console.warn("[ilha-router] head() called outside an SSR render window — ignored.");
51
+ return;
52
+ }
53
+ store.entries.push(input);
54
+ }
55
+ function cssEscapeAttr(value) {
56
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
57
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
58
+ }
59
+ function headManagedMetaSelector(tag) {
60
+ if ("charset" in tag) return `meta[charset][${ILHA_HEAD_ATTR}]`;
61
+ if ("name" in tag) return `meta[name="${cssEscapeAttr(tag.name)}"][${ILHA_HEAD_ATTR}]`;
62
+ if ("property" in tag) return `meta[property="${cssEscapeAttr(tag.property)}"][${ILHA_HEAD_ATTR}]`;
63
+ if ("http-equiv" in tag) return `meta[http-equiv="${cssEscapeAttr(tag["http-equiv"])}"][${ILHA_HEAD_ATTR}]`;
64
+ return null;
65
+ }
66
+ function headManagedLinkSelector(tag) {
67
+ if (tag.rel && tag.href) return `link[rel="${cssEscapeAttr(tag.rel)}"][href="${cssEscapeAttr(tag.href)}"][${ILHA_HEAD_ATTR}]`;
68
+ return null;
69
+ }
70
+ /**
71
+ * Apply merged head entries on client navigations. Updates `document.title` and
72
+ * managed meta/link nodes (`data-ilha-head`). Script tags from HeadInput are
73
+ * SSR-only and are not re-injected here. Removes managed tags from the previous
74
+ * route that are not part of this navigation's set.
75
+ */
76
+ function applyHeadEntriesToDocument(entries) {
77
+ if (!isBrowser) return;
78
+ let title;
79
+ let titleTemplate;
80
+ const meta = [];
81
+ const link = [];
82
+ let htmlAttrs = {};
83
+ let bodyAttrs = {};
84
+ for (const entry of entries) {
85
+ if (entry.title !== void 0) title = entry.title;
86
+ if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
87
+ if (entry.meta) meta.push(...entry.meta);
88
+ if (entry.link) link.push(...entry.link);
89
+ if (entry.htmlAttrs) htmlAttrs = {
90
+ ...htmlAttrs,
91
+ ...entry.htmlAttrs
92
+ };
93
+ if (entry.bodyAttrs) bodyAttrs = {
94
+ ...bodyAttrs,
95
+ ...entry.bodyAttrs
96
+ };
97
+ }
98
+ const resolvedTitle = applyTitleTemplate(title, titleTemplate);
99
+ if (resolvedTitle !== void 0) document.title = resolvedTitle;
100
+ const metaTags = dedupByKey(meta, metaDedupKey);
101
+ const linkTags = dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`);
102
+ const keepManaged = /* @__PURE__ */ new Set();
103
+ for (const tag of metaTags) {
104
+ const selector = headManagedMetaSelector(tag);
105
+ if (!selector) continue;
106
+ let el = document.querySelector(selector);
107
+ if (!el) {
108
+ el = document.createElement("meta");
109
+ el.setAttribute(ILHA_HEAD_ATTR, "");
110
+ document.head.appendChild(el);
111
+ }
112
+ for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
113
+ keepManaged.add(el);
114
+ }
115
+ for (const tag of linkTags) {
116
+ const selector = headManagedLinkSelector(tag);
117
+ let el = selector ? document.querySelector(selector) : null;
118
+ if (!el) {
119
+ el = document.createElement("link");
120
+ el.setAttribute(ILHA_HEAD_ATTR, "");
121
+ document.head.appendChild(el);
122
+ }
123
+ for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
124
+ keepManaged.add(el);
125
+ }
126
+ for (const el of document.head.querySelectorAll(`[${ILHA_HEAD_ATTR}]`)) if (!keepManaged.has(el)) el.remove();
127
+ const htmlEl = document.documentElement;
128
+ const prevHtmlKeys = (htmlEl.getAttribute(ILHA_ROUTER_HTML_ATTR) ?? "").split(/\s+/).filter(Boolean);
129
+ for (const k of prevHtmlKeys) htmlEl.removeAttribute(k);
130
+ const nextHtmlKeys = Object.keys(htmlAttrs);
131
+ for (const [k, v] of Object.entries(htmlAttrs)) htmlEl.setAttribute(k, v);
132
+ if (nextHtmlKeys.length) htmlEl.setAttribute(ILHA_ROUTER_HTML_ATTR, nextHtmlKeys.join(" "));
133
+ else htmlEl.removeAttribute(ILHA_ROUTER_HTML_ATTR);
134
+ const bodyEl = document.body;
135
+ const prevBodyKeys = (bodyEl.getAttribute(ILHA_ROUTER_BODY_ATTR) ?? "").split(/\s+/).filter(Boolean);
136
+ for (const k of prevBodyKeys) bodyEl.removeAttribute(k);
137
+ const nextBodyKeys = Object.keys(bodyAttrs);
138
+ for (const [k, v] of Object.entries(bodyAttrs)) bodyEl.setAttribute(k, v);
139
+ if (nextBodyKeys.length) bodyEl.setAttribute(ILHA_ROUTER_BODY_ATTR, nextBodyKeys.join(" "));
140
+ else bodyEl.removeAttribute(ILHA_ROUTER_BODY_ATTR);
141
+ }
142
+ async function withHeadStore(store, fn) {
143
+ if (isBrowser) {
144
+ const prev = _browserHeadStore;
145
+ _browserHeadStore = store;
146
+ try {
147
+ return await fn();
148
+ } finally {
149
+ _browserHeadStore = prev;
150
+ }
151
+ }
152
+ return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
153
+ }
154
+ const HEAD_ESC = {
155
+ "&": "&amp;",
156
+ "<": "&lt;",
157
+ ">": "&gt;",
158
+ "\"": "&quot;",
159
+ "'": "&#39;"
160
+ };
161
+ function escapeHeadAttr(value) {
162
+ return String(value).replace(/[&<>"']/g, (c) => HEAD_ESC[c]);
163
+ }
164
+ /** Escape text content for inline HTML (loader error messages etc.). */
165
+ function escapeHtml(value) {
166
+ return String(value).replace(/[&<>]/g, (c) => HEAD_ESC[c]);
167
+ }
168
+ function serializeAttrs(attrs) {
169
+ const parts = [];
170
+ for (const [k, v] of Object.entries(attrs)) {
171
+ if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(k) || /^on[a-z]/i.test(k)) {
172
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe head attribute "${k}".`);
173
+ continue;
174
+ }
175
+ parts.push(` ${k}="${escapeHeadAttr(v)}"`);
176
+ }
177
+ return parts.join("");
178
+ }
179
+ function isSafeUrl(value) {
180
+ const v = value.trim();
181
+ if (v === "") return true;
182
+ if (/[\\\u0000-\u0020]/.test(v)) return false;
183
+ if (v.startsWith("//")) return false;
184
+ if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./")) return true;
185
+ if (/^(?:javascript|vbscript|data):/i.test(v)) return false;
186
+ try {
187
+ const u = new URL(v, "http://localhost");
188
+ return u.protocol === "http:" || u.protocol === "https:";
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+ function metaDedupKey(tag) {
194
+ if ("charset" in tag) return "charset";
195
+ if ("name" in tag) return `name:${tag.name}`;
196
+ if ("property" in tag) return `property:${tag.property}`;
197
+ if ("http-equiv" in tag) return `http-equiv:${tag["http-equiv"]}`;
198
+ return JSON.stringify(tag);
199
+ }
200
+ function dedupByKey(tags, keyOf) {
201
+ const map = /* @__PURE__ */ new Map();
202
+ for (const tag of tags) map.set(keyOf(tag), tag);
203
+ return [...map.values()];
204
+ }
205
+ function applyTitleTemplate(title, template) {
206
+ if (template === void 0) return title;
207
+ if (typeof template === "function") return template(title);
208
+ return template.replace(/%s/g, title ?? "");
209
+ }
210
+ /**
211
+ * Merge head entries in contribution order (loader first as the base, then
212
+ * render-time outer→inner layouts, then the page) and serialize. Later entries
213
+ * win on collision; the last `titleTemplate` wraps the resolved title.
214
+ */
215
+ function serializeHead(entries) {
216
+ let title;
217
+ let titleTemplate;
218
+ const meta = [];
219
+ const link = [];
220
+ const script = [];
221
+ let htmlAttrs = {};
222
+ let bodyAttrs = {};
223
+ for (const entry of entries) {
224
+ if (entry.title !== void 0) title = entry.title;
225
+ if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
226
+ if (entry.meta) meta.push(...entry.meta);
227
+ if (entry.link) link.push(...entry.link);
228
+ if (entry.script) script.push(...entry.script);
229
+ if (entry.htmlAttrs) htmlAttrs = {
230
+ ...htmlAttrs,
231
+ ...entry.htmlAttrs
232
+ };
233
+ if (entry.bodyAttrs) bodyAttrs = {
234
+ ...bodyAttrs,
235
+ ...entry.bodyAttrs
236
+ };
237
+ }
238
+ const resolvedTitle = applyTitleTemplate(title, titleTemplate);
239
+ const parts = [];
240
+ if (resolvedTitle !== void 0) parts.push(`<title>${escapeHeadAttr(resolvedTitle)}</title>`);
241
+ for (const tag of dedupByKey(meta, metaDedupKey)) {
242
+ if (/^refresh$/i.test(tag["http-equiv"] ?? "") && !isSafeRefreshTarget(tag.content ?? "")) {
243
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe meta refresh target "${tag.content}".`);
244
+ continue;
245
+ }
246
+ parts.push(`<meta${serializeAttrs({
247
+ ...tag,
248
+ [ILHA_HEAD_ATTR]: ""
249
+ })} />`);
250
+ }
251
+ for (const tag of dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`)) {
252
+ if (tag.href !== void 0 && !isSafeUrl(tag.href)) {
253
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe link href "${tag.href}".`);
254
+ continue;
255
+ }
256
+ parts.push(`<link${serializeAttrs({
257
+ ...tag,
258
+ [ILHA_HEAD_ATTR]: ""
259
+ })} />`);
260
+ }
261
+ for (const tag of script) {
262
+ if (tag.src !== void 0 && !isSafeUrl(tag.src)) {
263
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe script src "${tag.src}".`);
264
+ continue;
265
+ }
266
+ const { children, ...attrs } = tag;
267
+ const body = (children ?? "").replace(/<\/script/gi, "<\\/script");
268
+ parts.push(`<script${serializeAttrs(attrs)}>${body}<\/script>`);
269
+ }
270
+ return {
271
+ headTags: parts.join("\n "),
272
+ htmlAttrs: serializeAttrs(htmlAttrs),
273
+ bodyAttrs: serializeAttrs(bodyAttrs)
274
+ };
275
+ }
276
+ function isSafeRefreshTarget(content) {
277
+ const match = /url\s*=\s*(['"]?)([^'";\s]+)\1/i.exec(content);
278
+ if (!match) return true;
279
+ const target = match[2] ?? "";
280
+ if (/[\\\u0000-\u0020]/.test(target)) return false;
281
+ if (target.startsWith("//")) return false;
282
+ return target.startsWith("/") || target.startsWith("./");
283
+ }
284
+
285
+ //#endregion
286
+ //#region src/snapshot.ts
287
+ /**
288
+ * Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
289
+ * attributes on the router side. Mirrors the core ilha guards: size cap,
290
+ * plain-object check, depth cap, and prototype-key stripping. Returns
291
+ * undefined (degrade gracefully) on any failure.
292
+ */
293
+ const MAX_SNAPSHOT_CHARS = 262144;
294
+ const MAX_SNAPSHOT_DEPTH = 32;
295
+ const UNSAFE_SNAPSHOT_KEYS = /* @__PURE__ */ new Set([
296
+ "__proto__",
297
+ "constructor",
298
+ "prototype"
299
+ ]);
300
+ function stripUnsafeKeys(value) {
301
+ if (value === null || typeof value !== "object") return;
302
+ if (Array.isArray(value)) {
303
+ for (const item of value) stripUnsafeKeys(item);
304
+ return;
305
+ }
306
+ for (const key of Object.getOwnPropertyNames(value)) if (UNSAFE_SNAPSHOT_KEYS.has(key)) delete value[key];
307
+ else stripUnsafeKeys(value[key]);
308
+ }
309
+ function exceedsDepth(value, depth) {
310
+ if (depth > MAX_SNAPSHOT_DEPTH) return true;
311
+ if (value === null || typeof value !== "object") return false;
312
+ if (Array.isArray(value)) {
313
+ for (const item of value) if (exceedsDepth(item, depth + 1)) return true;
314
+ return false;
315
+ }
316
+ for (const key in value) {
317
+ if (!Object.hasOwn(value, key)) continue;
318
+ if (exceedsDepth(value[key], depth + 1)) return true;
319
+ }
320
+ return false;
321
+ }
322
+ function parseSnapshotAttr(raw) {
323
+ if (raw.length > MAX_SNAPSHOT_CHARS) return void 0;
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(raw);
327
+ } catch {
328
+ return;
329
+ }
330
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
331
+ if (exceedsDepth(parsed, 1)) return void 0;
332
+ stripUnsafeKeys(parsed);
333
+ return parsed;
334
+ }
335
+
336
+ //#endregion
337
+ export { escapeHtml as a, withHeadStore as c, escapeHeadAttr as i, applyHeadEntriesToDocument as n, head as o, cssEscapeAttr as r, serializeHead as s, parseSnapshotAttr as t };
@@ -0,0 +1 @@
1
+ export declare function parseSnapshotAttr(raw: string): Record<string, unknown> | undefined;