@ilha/router 0.1.1

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/dist/index.js ADDED
@@ -0,0 +1,342 @@
1
+ import ilha, { context, html, mount } from "ilha";
2
+ import { addRoute, createRouter, findRoute } from "rou3";
3
+ //#region src/index.ts
4
+ const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
5
+ function wrapLayout(layout, page) {
6
+ return layout(page);
7
+ }
8
+ function wrapError(handler, page) {
9
+ const wrapper = ilha.render(() => {
10
+ try {
11
+ return page.toString();
12
+ } catch (e) {
13
+ const route = {
14
+ path: routePath(),
15
+ params: routeParams(),
16
+ search: routeSearch(),
17
+ hash: routeHash()
18
+ };
19
+ return handler({
20
+ message: e.message,
21
+ status: e.status,
22
+ stack: e.stack
23
+ }, route).toString();
24
+ }
25
+ });
26
+ wrapper.mount = (host, props) => {
27
+ try {
28
+ return page.mount(host, props);
29
+ } catch (e) {
30
+ const route = {
31
+ path: routePath(),
32
+ params: routeParams(),
33
+ search: routeSearch(),
34
+ hash: routeHash()
35
+ };
36
+ const errorIsland = handler({
37
+ message: e.message,
38
+ status: e.status,
39
+ stack: e.stack
40
+ }, route);
41
+ host.innerHTML = errorIsland.toString();
42
+ return errorIsland.mount(host, props);
43
+ }
44
+ };
45
+ return wrapper;
46
+ }
47
+ function buildReverseRegistry(registry) {
48
+ const map = /* @__PURE__ */ new Map();
49
+ for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
50
+ return map;
51
+ }
52
+ /**
53
+ * Mounts a route island with proper hydration for client-side navigation.
54
+ * Looks up the island in the reverse registry, renders it with hydration
55
+ * markers, and mounts it for interactivity.
56
+ */
57
+ async function mountRouteWithHydration(island, host, registry, reverseRegistry) {
58
+ if (!island) {
59
+ host.innerHTML = `<div data-router-empty></div>`;
60
+ return () => {};
61
+ }
62
+ if (!registry) {
63
+ console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
64
+ host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
65
+ return () => {};
66
+ }
67
+ const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
68
+ if (!name) {
69
+ console.warn("[ilha-router] Island not found in registry for client-side navigation.");
70
+ host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
71
+ return () => {};
72
+ }
73
+ host.innerHTML = `<div data-router-view>${await island.hydratable({}, {
74
+ name,
75
+ as: "div",
76
+ snapshot: true
77
+ })}</div>`;
78
+ const islandHost = host.querySelector(`[data-ilha="${name}"]`);
79
+ if (islandHost) return island.mount(islandHost);
80
+ return () => {};
81
+ }
82
+ const routePath = context("router.path", "");
83
+ const routeParams = context("router.params", {});
84
+ const routeSearch = context("router.search", "");
85
+ const routeHash = context("router.hash", "");
86
+ function useRoute() {
87
+ return {
88
+ path: routePath,
89
+ params: routeParams,
90
+ search: routeSearch,
91
+ hash: routeHash
92
+ };
93
+ }
94
+ const activeIsland = context("router.active", null);
95
+ let _records = [];
96
+ let _rou3 = createRouter();
97
+ let _islandToPattern = /* @__PURE__ */ new Map();
98
+ function extractParams(matchParams) {
99
+ const params = {};
100
+ if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = decodeURIComponent(v);
101
+ return params;
102
+ }
103
+ function syncRouteFromURL(url) {
104
+ const parsed = typeof url === "string" ? new URL(url, "http://localhost") : url;
105
+ const match = findRoute(_rou3, "GET", parsed.pathname);
106
+ routePath(parsed.pathname);
107
+ routeParams(extractParams(match?.params));
108
+ routeSearch(parsed.search);
109
+ routeHash(parsed.hash);
110
+ activeIsland(match?.data ?? null);
111
+ }
112
+ /** Client-only fast path — reads directly from `location` instead of parsing a URL. */
113
+ function syncRouteFromLocation() {
114
+ const match = findRoute(_rou3, "GET", location.pathname);
115
+ routePath(location.pathname);
116
+ routeParams(extractParams(match?.params));
117
+ routeSearch(location.search);
118
+ routeHash(location.hash);
119
+ activeIsland(match?.data ?? null);
120
+ }
121
+ /**
122
+ * Prime route context signals from the current `location` so that islands
123
+ * hydrated by `ilha.mount()` see the correct route values on their first
124
+ * render — preventing a mismatch morph that would destroy hydrated bindings.
125
+ *
126
+ * Call this **before** `ilha.mount()` and **after** all routes have been
127
+ * registered (i.e. after the `router().route(…).route(…)` chain).
128
+ *
129
+ * ```ts
130
+ * import { mount } from "ilha";
131
+ * import { pageRouter } from "ilha:pages";
132
+ * import { registry } from "ilha:registry";
133
+ *
134
+ * pageRouter.prime(); // ← sync signals first
135
+ * mount(registry, { root: … }); // ← then hydrate islands
136
+ * pageRouter.mount("#app", { hydrate: true });
137
+ * ```
138
+ */
139
+ function prime() {
140
+ if (isBrowser) syncRouteFromLocation();
141
+ }
142
+ function navigate(to, opts = {}) {
143
+ if (!isBrowser) return;
144
+ if (to === location.pathname + location.search + location.hash) return;
145
+ if (opts.replace) history.replaceState(null, "", to);
146
+ else history.pushState(null, "", to);
147
+ syncRouteFromLocation();
148
+ }
149
+ function enableLinkInterception(root = document) {
150
+ if (!isBrowser) return () => {};
151
+ const handler = (e) => {
152
+ if (e.defaultPrevented) return;
153
+ const target = e.target.closest("a");
154
+ if (!target) return;
155
+ const href = target.getAttribute("href");
156
+ if (!href) return;
157
+ const isAnchorOnly = href.startsWith("#");
158
+ const isBlank = target.getAttribute("target") === "_blank";
159
+ const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey;
160
+ const isExternal = !!target.hostname && (target.hostname !== location.hostname || target.protocol !== location.protocol);
161
+ const hasNoIntercept = target.hasAttribute("data-no-intercept");
162
+ if (isExternal || isAnchorOnly || isBlank || hasModifier || hasNoIntercept) return;
163
+ e.preventDefault();
164
+ navigate(target.pathname + target.search + target.hash);
165
+ };
166
+ root.addEventListener("click", handler);
167
+ return () => root.removeEventListener("click", handler);
168
+ }
169
+ const RouterView = ilha.render(() => {
170
+ const island = activeIsland();
171
+ if (!island) return `<div data-router-empty></div>`;
172
+ return `<div data-router-view>${island.toString()}</div>`;
173
+ });
174
+ const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
175
+ event.preventDefault();
176
+ navigate(state.href());
177
+ }).render(({ state }) => html`<a data-link href="${state.href}">${state.label}</a>`);
178
+ function isActive(pattern) {
179
+ const match = findRoute(_rou3, "GET", routePath());
180
+ if (!match) return false;
181
+ return _islandToPattern.get(match.data) === pattern;
182
+ }
183
+ function router() {
184
+ _records = [];
185
+ _rou3 = createRouter();
186
+ _islandToPattern = /* @__PURE__ */ new Map();
187
+ let _popstateCleanup = null;
188
+ let _linkCleanup = null;
189
+ const builder = {
190
+ route(pattern, island) {
191
+ _records.push({
192
+ pattern,
193
+ island
194
+ });
195
+ addRoute(_rou3, "GET", pattern, island);
196
+ if (!_islandToPattern.has(island)) _islandToPattern.set(island, pattern);
197
+ return builder;
198
+ },
199
+ prime,
200
+ mount(target, { hydrate = false, registry } = {}) {
201
+ if (!isBrowser) {
202
+ console.warn("[ilha-router] mount() called in a non-browser environment");
203
+ return () => {};
204
+ }
205
+ const host = typeof target === "string" ? document.querySelector(target) : target;
206
+ if (!host) {
207
+ console.warn(`[ilha-router] No element found for selector "${target}"`);
208
+ return () => {};
209
+ }
210
+ syncRouteFromLocation();
211
+ const popHandler = () => syncRouteFromLocation();
212
+ window.addEventListener("popstate", popHandler);
213
+ _popstateCleanup = () => window.removeEventListener("popstate", popHandler);
214
+ _linkCleanup = enableLinkInterception(document);
215
+ let unmountView = null;
216
+ if (hydrate) {
217
+ const viewHost = host.querySelector("[data-router-view]") ?? host;
218
+ let currentMountedIsland = activeIsland();
219
+ const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
220
+ let navVersion = 0;
221
+ const navHandler = ilha.render(() => {
222
+ const current = activeIsland();
223
+ if (current !== currentMountedIsland) {
224
+ const thisNav = ++navVersion;
225
+ queueMicrotask(async () => {
226
+ if (thisNav !== navVersion) return;
227
+ unmountView?.();
228
+ unmountView = await mountRouteWithHydration(current, viewHost, registry, reverseRegistry);
229
+ currentMountedIsland = current;
230
+ });
231
+ }
232
+ return "";
233
+ });
234
+ const navHost = document.createElement("div");
235
+ navHost.style.display = "none";
236
+ host.appendChild(navHost);
237
+ const unmountNavHandler = navHandler.mount(navHost);
238
+ return () => {
239
+ ++navVersion;
240
+ unmountNavHandler();
241
+ navHost.remove();
242
+ unmountView?.();
243
+ _popstateCleanup?.();
244
+ _linkCleanup?.();
245
+ _popstateCleanup = null;
246
+ _linkCleanup = null;
247
+ };
248
+ }
249
+ let unmountIsland = null;
250
+ let currentMountedIsland = null;
251
+ let navVersion = 0;
252
+ unmountView = RouterView.mount(host);
253
+ /** Mount the active island onto the [data-router-view] container for interactivity. */
254
+ function mountActiveIsland(island) {
255
+ unmountIsland?.();
256
+ unmountIsland = null;
257
+ currentMountedIsland = island;
258
+ if (!island) return;
259
+ const viewHost = host?.querySelector("[data-router-view]");
260
+ if (viewHost) unmountIsland = island.mount(viewHost);
261
+ }
262
+ mountActiveIsland(activeIsland());
263
+ const navHandler = ilha.render(() => {
264
+ const current = activeIsland();
265
+ if (current !== currentMountedIsland) {
266
+ const thisNav = ++navVersion;
267
+ queueMicrotask(() => {
268
+ if (thisNav !== navVersion) return;
269
+ mountActiveIsland(current);
270
+ });
271
+ }
272
+ return "";
273
+ });
274
+ const navHost = document.createElement("div");
275
+ navHost.style.display = "none";
276
+ host.appendChild(navHost);
277
+ const unmountNavHandler = navHandler.mount(navHost);
278
+ return () => {
279
+ ++navVersion;
280
+ unmountIsland?.();
281
+ unmountNavHandler();
282
+ navHost.remove();
283
+ unmountView?.();
284
+ _popstateCleanup?.();
285
+ _linkCleanup?.();
286
+ _popstateCleanup = null;
287
+ _linkCleanup = null;
288
+ };
289
+ },
290
+ render(url) {
291
+ syncRouteFromURL(url);
292
+ return RouterView.toString();
293
+ },
294
+ async renderHydratable(url, registry, options = {}) {
295
+ syncRouteFromURL(url);
296
+ const island = activeIsland();
297
+ if (!island) return `<div data-router-empty></div>`;
298
+ const name = buildReverseRegistry(registry).get(island);
299
+ if (!name) {
300
+ console.warn(`[ilha-router] renderHydratable: active island for "${routePath()}" is not in the registry. Falling back to plain SSR — the island will not be interactive on the client.`);
301
+ return `<div data-router-view>${island.toString()}</div>`;
302
+ }
303
+ return `<div data-router-view>${await island.hydratable({}, {
304
+ name,
305
+ as: "div",
306
+ snapshot: true,
307
+ ...options
308
+ })}</div>`;
309
+ },
310
+ hydrate(registry, options = {}) {
311
+ if (!isBrowser) {
312
+ console.warn("[ilha-router] hydrate() called in a non-browser environment");
313
+ return () => {};
314
+ }
315
+ const root = options.root ?? document.body;
316
+ const target = options.target ?? root;
317
+ prime();
318
+ const { unmount } = mount(registry, { root });
319
+ const unmountRouter = this.mount(target, {
320
+ hydrate: true,
321
+ registry
322
+ });
323
+ return () => {
324
+ unmount();
325
+ unmountRouter();
326
+ };
327
+ }
328
+ };
329
+ return builder;
330
+ }
331
+ var src_default = {
332
+ router,
333
+ navigate,
334
+ useRoute,
335
+ isActive,
336
+ enableLinkInterception,
337
+ prime,
338
+ RouterView,
339
+ RouterLink
340
+ };
341
+ //#endregion
342
+ export { RouterLink, RouterView, src_default as default, enableLinkInterception, isActive, navigate, prime, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };