@ilha/router 0.2.5 → 0.3.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.
package/dist/index.js CHANGED
@@ -1,637 +1,2 @@
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
- /**
6
- * Identity function for declaring a loader. Exists purely as a type anchor and
7
- * a marker for the Vite plugin to detect by export name.
8
- */
9
- function loader(fn) {
10
- return fn;
11
- }
12
- var Redirect = class {
13
- __ilhaRedirect = true;
14
- to;
15
- status;
16
- constructor(to, status = 302) {
17
- this.to = to;
18
- this.status = status;
19
- }
20
- };
21
- var LoaderError = class {
22
- __ilhaLoaderError = true;
23
- status;
24
- message;
25
- constructor(status, message) {
26
- this.status = status;
27
- this.message = message;
28
- }
29
- };
30
- function redirect(to, status = 302) {
31
- throw new Redirect(to, status);
32
- }
33
- function error(status, message) {
34
- throw new LoaderError(status, message);
35
- }
36
- /**
37
- * Compose a list of loaders into a single loader. Later loaders win on key
38
- * collision (page loader overrides layout loader for the same key). All loaders
39
- * run concurrently within a chain since they share the same abort signal and
40
- * request — re-fetching is cheap with a request-scoped cache (future work).
41
- *
42
- * For v1 we run them in parallel via `Promise.all`. If a loader throws a
43
- * `Redirect` or `LoaderError`, the composed loader re-throws it unchanged.
44
- */
45
- function composeLoaders(loaders) {
46
- if (loaders.length === 0) return async () => ({});
47
- if (loaders.length === 1) return loaders[0];
48
- return async (ctx) => {
49
- const results = await Promise.all(loaders.map((l) => l(ctx)));
50
- return Object.assign({}, ...results);
51
- };
52
- }
53
- function wrapLayout(layout, page) {
54
- return layout(page);
55
- }
56
- function wrapError(handler, page) {
57
- const Wrapper = ilha.render(() => {
58
- try {
59
- return page.toString();
60
- } catch (e) {
61
- const route = {
62
- path: routePath(),
63
- params: routeParams(),
64
- search: routeSearch(),
65
- hash: routeHash()
66
- };
67
- return handler({
68
- message: e.message,
69
- status: e.status,
70
- stack: e.stack
71
- }, route).toString();
72
- }
73
- });
74
- Wrapper.mount = (host, props) => {
75
- try {
76
- return page.mount(host, props);
77
- } catch (e) {
78
- const route = {
79
- path: routePath(),
80
- params: routeParams(),
81
- search: routeSearch(),
82
- hash: routeHash()
83
- };
84
- const errorIsland = handler({
85
- message: e.message,
86
- status: e.status,
87
- stack: e.stack
88
- }, route);
89
- host.innerHTML = errorIsland.toString();
90
- return errorIsland.mount(host, props);
91
- }
92
- };
93
- return Wrapper;
94
- }
95
- function defineLayout(layout) {
96
- return layout;
97
- }
98
- function buildReverseRegistry(registry) {
99
- const map = /* @__PURE__ */ new Map();
100
- for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
101
- return map;
102
- }
103
- /** Path of the loader endpoint served by the Vite plugin / production adapter. */
104
- const LOADER_ENDPOINT = "/__ilha/loader";
105
- /** In-memory cache for prefetched loader data, keyed by path+search. */
106
- const prefetchCache = /* @__PURE__ */ new Map();
107
- async function fetchLoaderData(pathWithSearch, signal) {
108
- const cached = prefetchCache.get(pathWithSearch);
109
- if (cached) {
110
- prefetchCache.delete(pathWithSearch);
111
- try {
112
- return await cached;
113
- } catch {}
114
- }
115
- const url = `${LOADER_ENDPOINT}?path=${encodeURIComponent(pathWithSearch)}`;
116
- try {
117
- const res = await fetch(url, {
118
- signal,
119
- headers: { accept: "application/json" }
120
- });
121
- if (!res.ok) {
122
- try {
123
- const body = await res.json();
124
- if (body && typeof body === "object" && "kind" in body) return body;
125
- } catch {}
126
- return {
127
- kind: "error",
128
- status: res.status,
129
- message: res.statusText
130
- };
131
- }
132
- return await res.json();
133
- } catch (e) {
134
- if (e?.name === "AbortError") throw e;
135
- return {
136
- kind: "error",
137
- status: 0,
138
- message: e?.message ?? "network error"
139
- };
140
- }
141
- }
142
- /**
143
- * Prefetch loader data for a given path. Safe to call repeatedly — a single
144
- * inflight request is reused until it either resolves (and is consumed by
145
- * navigation) or is superseded by another prefetch.
146
- */
147
- function prefetch(pathWithSearch) {
148
- if (!isBrowser) return;
149
- if (prefetchCache.has(pathWithSearch)) return;
150
- const pathOnly = pathWithSearch.split("?")[0] ?? "";
151
- if (!findRoute(_rou3, "GET", pathOnly)?.data?.loader) return;
152
- const promise = fetchLoaderData(pathWithSearch).catch((e) => {
153
- return {
154
- kind: "error",
155
- status: 0,
156
- message: e?.message ?? "prefetch failed"
157
- };
158
- });
159
- prefetchCache.set(pathWithSearch, promise);
160
- }
161
- /**
162
- * Mounts a route island with proper hydration for client-side navigation.
163
- * Looks up the island in the reverse registry, runs the loader (via fetch),
164
- * renders it with hydration markers, and mounts it for interactivity.
165
- */
166
- async function mountRouteWithHydration(island, host, pathWithSearch, signal, registry, reverseRegistry) {
167
- if (!island) {
168
- host.innerHTML = `<div data-router-empty></div>`;
169
- return () => {};
170
- }
171
- const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.loader;
172
- let props = {};
173
- const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
174
- kind: "data",
175
- data: {}
176
- };
177
- if (loaderResult.kind === "redirect") {
178
- navigate(loaderResult.to, { replace: true });
179
- return () => {};
180
- }
181
- if (loaderResult.kind === "error") {
182
- const escaped = String(loaderResult.message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
183
- host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
184
- return () => {};
185
- }
186
- if (loaderResult.kind === "not-found") {
187
- host.innerHTML = `<div data-router-empty></div>`;
188
- return () => {};
189
- }
190
- props = loaderResult.data;
191
- if (!registry) {
192
- console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
193
- host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
194
- return () => {};
195
- }
196
- const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
197
- if (!name) {
198
- console.warn("[ilha-router] Island not found in registry for client-side navigation.");
199
- host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
200
- return () => {};
201
- }
202
- host.innerHTML = `<div data-router-view>${await island.hydratable(props, {
203
- name,
204
- as: "div",
205
- snapshot: true
206
- })}</div>`;
207
- const islandHost = host.querySelector(`[data-ilha="${name}"]`);
208
- if (islandHost) return island.mount(islandHost);
209
- return () => {};
210
- }
211
- const routePath = context("router.path", "");
212
- const routeParams = context("router.params", {});
213
- const routeSearch = context("router.search", "");
214
- const routeHash = context("router.hash", "");
215
- function useRoute() {
216
- return {
217
- path: routePath,
218
- params: routeParams,
219
- search: routeSearch,
220
- hash: routeHash
221
- };
222
- }
223
- const activeIsland = context("router.active", null);
224
- let _records = [];
225
- let _rou3 = createRouter();
226
- let _islandToPattern = /* @__PURE__ */ new Map();
227
- let _patternToData = /* @__PURE__ */ new Map();
228
- function extractParams(matchParams) {
229
- const params = {};
230
- if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = decodeURIComponent(v);
231
- return params;
232
- }
233
- function syncRouteFromURL(url) {
234
- const parsed = typeof url === "string" ? new URL(url, "http://localhost") : url;
235
- const match = findRoute(_rou3, "GET", parsed.pathname);
236
- routePath(parsed.pathname);
237
- routeParams(extractParams(match?.params));
238
- routeSearch(parsed.search);
239
- routeHash(parsed.hash);
240
- activeIsland(match?.data?.island ?? null);
241
- }
242
- /** Client-only fast path — reads directly from `location` instead of parsing a URL. */
243
- function syncRouteFromLocation() {
244
- const match = findRoute(_rou3, "GET", location.pathname);
245
- routePath(location.pathname);
246
- routeParams(extractParams(match?.params));
247
- routeSearch(location.search);
248
- routeHash(location.hash);
249
- activeIsland(match?.data?.island ?? null);
250
- }
251
- /**
252
- * Prime route context signals from the current `location` so that islands
253
- * hydrated by `ilha.mount()` see the correct route values on their first
254
- * render — preventing a mismatch morph that would destroy hydrated bindings.
255
- */
256
- function prime() {
257
- if (isBrowser) syncRouteFromLocation();
258
- }
259
- function navigate(to, opts = {}) {
260
- if (!isBrowser) return;
261
- if (to === location.pathname + location.search + location.hash) return;
262
- if (opts.replace) history.replaceState(null, "", to);
263
- else history.pushState(null, "", to);
264
- syncRouteFromLocation();
265
- }
266
- function enableLinkInterception(root = document, options = {}) {
267
- if (!isBrowser) return () => {};
268
- const prefetchEnabled = options.prefetch !== false;
269
- /** Determine whether this anchor is a same-origin in-app link we should handle. */
270
- function eligibleLink(target, e) {
271
- const href = target.getAttribute("href");
272
- if (!href) return false;
273
- const isAnchorOnly = href.startsWith("#");
274
- const isBlank = target.getAttribute("target") === "_blank";
275
- const hasModifier = !!e && (e.ctrlKey || e.metaKey || e.shiftKey);
276
- const isExternal = !!target.hostname && (target.hostname !== location.hostname || target.protocol !== location.protocol);
277
- const hasNoIntercept = target.hasAttribute("data-no-intercept");
278
- return !(isExternal || isAnchorOnly || isBlank || hasModifier || hasNoIntercept);
279
- }
280
- const clickHandler = (e) => {
281
- if (e.defaultPrevented) return;
282
- const target = e.target.closest("a");
283
- if (!target) return;
284
- if (!eligibleLink(target, e)) return;
285
- e.preventDefault();
286
- navigate(target.pathname + target.search + target.hash);
287
- };
288
- const hoverHandler = (e) => {
289
- const target = e.target.closest("a");
290
- if (!target) return;
291
- const flag = target.getAttribute("data-prefetch");
292
- if (flag === null || flag === "false") return;
293
- if (!eligibleLink(target)) return;
294
- prefetch(target.pathname + target.search);
295
- };
296
- root.addEventListener("click", clickHandler);
297
- if (prefetchEnabled) root.addEventListener("mouseover", hoverHandler, { passive: true });
298
- return () => {
299
- root.removeEventListener("click", clickHandler);
300
- if (prefetchEnabled) root.removeEventListener("mouseover", hoverHandler);
301
- };
302
- }
303
- const RouterView = ilha.render(() => {
304
- const island = activeIsland();
305
- if (!island) return `<div data-router-empty></div>`;
306
- return `<div data-router-view>${island.toString()}</div>`;
307
- });
308
- const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
309
- event.preventDefault();
310
- navigate(state.href());
311
- }).on("[data-link]@mouseenter", ({ state }) => {
312
- const href = state.href();
313
- if (!href) return;
314
- if (/^https?:\/\//i.test(href)) try {
315
- const u = new URL(href);
316
- if (u.origin !== location.origin) return;
317
- prefetch(u.pathname + u.search);
318
- return;
319
- } catch {
320
- return;
321
- }
322
- prefetch(href);
323
- }).render(({ state }) => html`<a data-link data-prefetch href="${state.href}">${state.label}</a>`);
324
- function isActive(pattern) {
325
- const match = findRoute(_rou3, "GET", routePath());
326
- if (!match) return false;
327
- return _islandToPattern.get(match.data.island) === pattern;
328
- }
329
- function parsedURL(url) {
330
- return typeof url === "string" ? new URL(url, "http://localhost") : url;
331
- }
332
- function defaultRequest(url) {
333
- try {
334
- return new Request(url.toString());
335
- } catch {
336
- return {
337
- url: url.toString(),
338
- headers: new Headers()
339
- };
340
- }
341
- }
342
- async function executeLoader(loader, url, params, request, signal) {
343
- try {
344
- return {
345
- kind: "data",
346
- data: await loader({
347
- params,
348
- request,
349
- url,
350
- signal
351
- }) ?? {}
352
- };
353
- } catch (e) {
354
- if (e instanceof Redirect) return {
355
- kind: "redirect",
356
- to: e.to,
357
- status: e.status
358
- };
359
- if (e instanceof LoaderError) return {
360
- kind: "error",
361
- status: e.status,
362
- message: e.message
363
- };
364
- return {
365
- kind: "error",
366
- status: e?.status ?? 500,
367
- message: e?.message ?? "Loader failed"
368
- };
369
- }
370
- }
371
- function router() {
372
- _records = [];
373
- _rou3 = createRouter();
374
- _islandToPattern = /* @__PURE__ */ new Map();
375
- _patternToData = /* @__PURE__ */ new Map();
376
- let _popstateCleanup = null;
377
- let _linkCleanup = null;
378
- const builder = {
379
- route(pattern, island, loader) {
380
- const data = {
381
- island,
382
- loader
383
- };
384
- _records.push({
385
- pattern,
386
- island,
387
- loader
388
- });
389
- addRoute(_rou3, "GET", pattern, data);
390
- _patternToData.set(pattern, data);
391
- if (!_islandToPattern.has(island)) _islandToPattern.set(island, pattern);
392
- return builder;
393
- },
394
- attachLoader(pattern, loader) {
395
- const data = _patternToData.get(pattern);
396
- if (!data) {
397
- console.warn(`[ilha-router] attachLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
398
- return builder;
399
- }
400
- data.loader = loader;
401
- const rec = _records.find((r) => r.pattern === pattern);
402
- if (rec) rec.loader = loader;
403
- return builder;
404
- },
405
- prime,
406
- mount(target, { hydrate = false, registry } = {}) {
407
- if (!isBrowser) {
408
- console.warn("[ilha-router] mount() called in a non-browser environment");
409
- return () => {};
410
- }
411
- const host = typeof target === "string" ? document.querySelector(target) : target;
412
- if (!host) {
413
- console.warn(`[ilha-router] No element found for selector "${target}"`);
414
- return () => {};
415
- }
416
- syncRouteFromLocation();
417
- const popHandler = () => syncRouteFromLocation();
418
- window.addEventListener("popstate", popHandler);
419
- _popstateCleanup = () => window.removeEventListener("popstate", popHandler);
420
- _linkCleanup = enableLinkInterception(document);
421
- let unmountView = null;
422
- let navAbort = null;
423
- if (hydrate) {
424
- const viewHost = host.querySelector("[data-router-view]") ?? host;
425
- let currentMountedIsland = activeIsland();
426
- const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
427
- let navVersion = 0;
428
- const NavHandler = ilha.render(() => {
429
- const current = activeIsland();
430
- if (current !== currentMountedIsland) {
431
- const thisNav = ++navVersion;
432
- navAbort?.abort();
433
- navAbort = new AbortController();
434
- const signal = navAbort.signal;
435
- queueMicrotask(async () => {
436
- if (thisNav !== navVersion) return;
437
- unmountView?.();
438
- try {
439
- unmountView = await mountRouteWithHydration(current, viewHost, location.pathname + location.search, signal, registry, reverseRegistry);
440
- } catch (e) {
441
- if (e?.name === "AbortError") return;
442
- throw e;
443
- }
444
- currentMountedIsland = current;
445
- });
446
- }
447
- return "";
448
- });
449
- const navHost = document.createElement("div");
450
- navHost.style.display = "none";
451
- host.appendChild(navHost);
452
- const unmountNavHandler = NavHandler.mount(navHost);
453
- return () => {
454
- ++navVersion;
455
- navAbort?.abort();
456
- unmountNavHandler();
457
- navHost.remove();
458
- unmountView?.();
459
- _popstateCleanup?.();
460
- _linkCleanup?.();
461
- _popstateCleanup = null;
462
- _linkCleanup = null;
463
- };
464
- }
465
- let unmountIsland = null;
466
- let currentMountedIsland = null;
467
- let navVersion = 0;
468
- unmountView = RouterView.mount(host);
469
- /**
470
- * Fetch loader data and mount the active island. SPA mode also fetches
471
- * from the loader endpoint — otherwise navigation after the initial SSR
472
- * render would have no access to loader data.
473
- */
474
- async function mountActiveIsland(island, signal) {
475
- unmountIsland?.();
476
- unmountIsland = null;
477
- currentMountedIsland = island;
478
- if (!island) return;
479
- const viewHost = host?.querySelector("[data-router-view]");
480
- if (!viewHost) return;
481
- const result = !!findRoute(_rou3, "GET", location.pathname)?.data?.loader ? await fetchLoaderData(location.pathname + location.search, signal) : {
482
- kind: "data",
483
- data: {}
484
- };
485
- if (signal.aborted) return;
486
- if (result.kind === "redirect") {
487
- navigate(result.to, { replace: true });
488
- return;
489
- }
490
- const props = result.kind === "data" ? result.data : {};
491
- viewHost.innerHTML = island.toString(props);
492
- unmountIsland = island.mount(viewHost, props);
493
- }
494
- navAbort = new AbortController();
495
- mountActiveIsland(activeIsland(), navAbort.signal);
496
- const NavHandler = ilha.render(() => {
497
- const current = activeIsland();
498
- if (current !== currentMountedIsland) {
499
- const thisNav = ++navVersion;
500
- navAbort?.abort();
501
- navAbort = new AbortController();
502
- const signal = navAbort.signal;
503
- queueMicrotask(() => {
504
- if (thisNav !== navVersion) return;
505
- mountActiveIsland(current, signal);
506
- });
507
- }
508
- return "";
509
- });
510
- const navHost = document.createElement("div");
511
- navHost.style.display = "none";
512
- host.appendChild(navHost);
513
- const unmountNavHandler = NavHandler.mount(navHost);
514
- return () => {
515
- ++navVersion;
516
- navAbort?.abort();
517
- unmountIsland?.();
518
- unmountNavHandler();
519
- navHost.remove();
520
- unmountView?.();
521
- _popstateCleanup?.();
522
- _linkCleanup?.();
523
- _popstateCleanup = null;
524
- _linkCleanup = null;
525
- };
526
- },
527
- render(url) {
528
- syncRouteFromURL(url);
529
- return RouterView.toString();
530
- },
531
- async renderHydratable(url, registry, options = {}, request) {
532
- const response = await this.renderResponse(url, registry, options, request);
533
- if (response.kind === "html") return response.html;
534
- if (response.kind === "error") return response.html;
535
- return `<meta http-equiv="refresh" content="0; url=${response.to}">`;
536
- },
537
- async renderResponse(url, registry, options = {}, request) {
538
- const parsed = parsedURL(url);
539
- syncRouteFromURL(parsed);
540
- const match = findRoute(_rou3, "GET", parsed.pathname);
541
- const island = match?.data?.island ?? null;
542
- if (!island) return {
543
- kind: "html",
544
- html: `<div data-router-empty></div>`,
545
- status: 404
546
- };
547
- let props = {};
548
- if (match?.data?.loader) {
549
- const req = request ?? defaultRequest(parsed);
550
- const ctrl = new AbortController();
551
- const result = await executeLoader(match.data.loader, parsed, routeParams(), req, ctrl.signal);
552
- if (result.kind === "redirect") return {
553
- kind: "redirect",
554
- to: result.to,
555
- status: result.status
556
- };
557
- if (result.kind === "error") {
558
- const escapedMessage = String(result.message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
559
- const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
560
- return {
561
- kind: "error",
562
- status: result.status,
563
- message: result.message,
564
- html
565
- };
566
- }
567
- props = result.data;
568
- }
569
- const name = buildReverseRegistry(registry).get(island);
570
- if (!name) {
571
- 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.`);
572
- return {
573
- kind: "html",
574
- html: `<div data-router-view>${island.toString(props)}</div>`
575
- };
576
- }
577
- return {
578
- kind: "html",
579
- html: `<div data-router-view>${await island.hydratable(props, {
580
- name,
581
- as: "div",
582
- snapshot: true,
583
- ...options
584
- })}</div>`
585
- };
586
- },
587
- async runLoader(url, request) {
588
- const parsed = parsedURL(url);
589
- const match = findRoute(_rou3, "GET", parsed.pathname);
590
- if (!match?.data?.island) return { kind: "not-found" };
591
- if (!match.data.loader) return {
592
- kind: "data",
593
- data: {}
594
- };
595
- const params = extractParams(match.params);
596
- const req = request ?? defaultRequest(parsed);
597
- const ctrl = new AbortController();
598
- return executeLoader(match.data.loader, parsed, params, req, ctrl.signal);
599
- },
600
- hydrate(registry, options = {}) {
601
- if (!isBrowser) {
602
- console.warn("[ilha-router] hydrate() called in a non-browser environment");
603
- return () => {};
604
- }
605
- const root = options.root ?? document.body;
606
- const target = options.target ?? root;
607
- prime();
608
- const { unmount } = mount(registry, { root });
609
- const unmountRouter = this.mount(target, {
610
- hydrate: true,
611
- registry
612
- });
613
- return () => {
614
- unmount();
615
- unmountRouter();
616
- };
617
- }
618
- };
619
- return builder;
620
- }
621
- var src_default = {
622
- router,
623
- navigate,
624
- useRoute,
625
- isActive,
626
- enableLinkInterception,
627
- prime,
628
- prefetch,
629
- RouterView,
630
- RouterLink,
631
- loader,
632
- redirect,
633
- error,
634
- composeLoaders
635
- };
636
- //#endregion
637
- export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };
1
+ import { C as wrapError, E as setHistoryMode, S as useRoute, T as getHistoryMode, _ as routeParams, a as RouterView, b as router, c as enableLinkInterception, d as loader, f as navigate, g as routeHash, h as redirect, i as RouterLink, l as error, m as prime, n as LoaderError, o as composeLoaders, p as prefetch, r as Redirect, s as defineLayout, t as LOADER_ENDPOINT, u as isActive, v as routePath, w as wrapLayout, x as src_default, y as routeSearch } from "./src-C2qmhASZ.js";
2
+ export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, getHistoryMode, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, setHistoryMode, useRoute, wrapError, wrapLayout };