@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/README.md +547 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +342 -0
- package/dist/vite.d.ts +9816 -0
- package/dist/vite.js +205 -0
- package/package.json +36 -0
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 };
|