@inflector/keeno-router 0.1.1 → 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.d.ts +66 -0
- package/dist/index.js +200 -20
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,38 @@ export interface RouteDefinition {
|
|
|
13
13
|
path: string;
|
|
14
14
|
/** Receives `RouteProps`. Typed loosely so components can declare their own params type. */
|
|
15
15
|
component: Component<any>;
|
|
16
|
+
/**
|
|
17
|
+
* Layouts around the component, outermost first. Each receives `params` and `children`. A layout
|
|
18
|
+
* is kept while the routes navigated between share it at the same depth.
|
|
19
|
+
*/
|
|
20
|
+
layouts?: Component<any>[];
|
|
21
|
+
/**
|
|
22
|
+
* Pages for errors thrown while the component renders, by HTTP status (`httpError(404)`, other
|
|
23
|
+
* errors are 500). Each receives `ErrorPageProps` and renders inside the route's layouts.
|
|
24
|
+
*/
|
|
25
|
+
errors?: Record<number, Component<any>>;
|
|
16
26
|
}
|
|
27
|
+
/** Props passed to a layout. */
|
|
28
|
+
export interface LayoutProps extends RouteProps {
|
|
29
|
+
children: Children;
|
|
30
|
+
}
|
|
31
|
+
/** Props passed to an error page (`_404.kn`, `_500.kn`, ...). */
|
|
32
|
+
export interface ErrorPageProps extends RouteProps {
|
|
33
|
+
status: number;
|
|
34
|
+
/** What was thrown: an `HttpError` for `httpError()`, anything else for a 500. */
|
|
35
|
+
error: unknown;
|
|
36
|
+
/** Render the page again. */
|
|
37
|
+
reset: () => void;
|
|
38
|
+
}
|
|
39
|
+
/** An error carrying an HTTP status: throw it from a page to show that status's error page. */
|
|
40
|
+
export declare class HttpError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
constructor(status: number, message?: string);
|
|
43
|
+
}
|
|
44
|
+
/** `throw httpError(403)` renders the route's 403 page (`_403.kn`). */
|
|
45
|
+
export declare function httpError(status: number, message?: string): HttpError;
|
|
46
|
+
/** The HTTP status of a thrown value: its numeric `status`, else 500. */
|
|
47
|
+
export declare function errorStatus(error: unknown): number;
|
|
17
48
|
/** The current route. Its fields are reactive: reading them in a template or `update` tracks them. */
|
|
18
49
|
export interface Route {
|
|
19
50
|
/** Path without the router's `base`, e.g. `/users/1`. */
|
|
@@ -34,6 +65,11 @@ export interface NavigateOptions {
|
|
|
34
65
|
state?: unknown;
|
|
35
66
|
/** Scroll to the top after navigating (default true, unless the URL has a hash). */
|
|
36
67
|
scroll?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Animate the change with the View Transitions API, where the browser supports it (default:
|
|
70
|
+
* the `viewTransition` prop of `<Router>`). The new page renders inside the transition.
|
|
71
|
+
*/
|
|
72
|
+
transition?: boolean;
|
|
37
73
|
}
|
|
38
74
|
/** Go to an app path (`/users/1`, prefixed with `base`) or a relative URL (`?tab=2`). */
|
|
39
75
|
export declare function navigate(to: string, options?: NavigateOptions): void;
|
|
@@ -54,6 +90,11 @@ export interface RouterProps {
|
|
|
54
90
|
url?: string;
|
|
55
91
|
/** Rendered when no route matches. */
|
|
56
92
|
fallback?: () => unknown;
|
|
93
|
+
/**
|
|
94
|
+
* Animate navigations (links, `navigate()`, back and forward) with the View Transitions API,
|
|
95
|
+
* where the browser supports it. A navigation can opt out with `transition: false`.
|
|
96
|
+
*/
|
|
97
|
+
viewTransition?: boolean;
|
|
57
98
|
}
|
|
58
99
|
/**
|
|
59
100
|
* Render the component of the first route matching the URL. The component is kept when only
|
|
@@ -67,6 +108,8 @@ export interface LinkProps {
|
|
|
67
108
|
href: string;
|
|
68
109
|
/** Replace the current history entry. */
|
|
69
110
|
replace?: boolean;
|
|
111
|
+
/** Animate the navigation with the View Transitions API (default: `<Router viewTransition>`). */
|
|
112
|
+
transition?: boolean;
|
|
70
113
|
/** Class added while the link's path is active (default `active`). */
|
|
71
114
|
activeClass?: string;
|
|
72
115
|
/** Only active on exactly this path, not below it. */
|
|
@@ -79,3 +122,26 @@ export interface LinkProps {
|
|
|
79
122
|
* while active. The scoped styles of the component that renders it apply to its `<a>`.
|
|
80
123
|
*/
|
|
81
124
|
export declare function Link(props: LinkProps): unknown;
|
|
125
|
+
/** A page module: loaded (`{ default }`) or a loader (`() => import('./page.kn')`). */
|
|
126
|
+
export type PageModule = {
|
|
127
|
+
default: Component<any>;
|
|
128
|
+
} | (() => Promise<{
|
|
129
|
+
default: Component<any>;
|
|
130
|
+
}>);
|
|
131
|
+
/**
|
|
132
|
+
* Routes from a directory of page files, as `import.meta.glob('/src/pages/**\/*.kn')` returns it
|
|
133
|
+
* (keys starting with `dir`):
|
|
134
|
+
*
|
|
135
|
+
* - `index.kn` is the directory's path, `about.kn` is `/about`, `[id].kn` is `/:id`.
|
|
136
|
+
* - `_layout.kn` wraps every page of its directory and the ones below it.
|
|
137
|
+
* - `_all.kn` matches every path below its directory that no other page does (`params['*']`).
|
|
138
|
+
* - `_404.kn` renders for paths below its directory that match nothing, and `_500.kn`, `_403.kn`…
|
|
139
|
+
* for errors with that status thrown by a page (`throw httpError(403)`; other errors are 500).
|
|
140
|
+
* - Other files starting with `_` are left out, so pages can keep their own components next to them.
|
|
141
|
+
*/
|
|
142
|
+
export declare function pageRoutes(files: Record<string, PageModule>, dir?: string): RouteDefinition[];
|
|
143
|
+
/**
|
|
144
|
+
* Load the page that matches `url` (an app path or full URL), if it loads on demand. Await it
|
|
145
|
+
* before `renderToString` or `hydrate`, so the page renders in the first pass.
|
|
146
|
+
*/
|
|
147
|
+
export declare function preloadRoute(routes: RouteDefinition[], url: string): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
// ../../../../../../../tmp/keeno-build/_inflector_keeno-router/src/index.ts
|
|
2
|
-
import { State, createContext, derived, inject, provide, untrack } from "@inflector/keeno";
|
|
2
|
+
import { ErrorBoundary, State, createContext, derived, flush, inject, lazy, provide, untrack } from "@inflector/keeno";
|
|
3
3
|
import { attr, callerScope, createComponent, insert, on, template } from "@inflector/keeno/internal";
|
|
4
|
+
|
|
5
|
+
class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message ?? `HTTP ${status}`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "HttpError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function httpError(status, message) {
|
|
14
|
+
return new HttpError(status, message);
|
|
15
|
+
}
|
|
16
|
+
function errorStatus(error) {
|
|
17
|
+
const status = error?.status;
|
|
18
|
+
return typeof status === "number" && status >= 100 && status <= 599 ? status : 500;
|
|
19
|
+
}
|
|
4
20
|
var hasWindow = typeof window !== "undefined" && typeof location !== "undefined";
|
|
5
21
|
function readLocation() {
|
|
6
22
|
if (!hasWindow)
|
|
@@ -42,6 +58,21 @@ function restoreScroll(l) {
|
|
|
42
58
|
else
|
|
43
59
|
setTimeout(run, 0);
|
|
44
60
|
}
|
|
61
|
+
var transitionsDefault = false;
|
|
62
|
+
function withTransition(enabled, update) {
|
|
63
|
+
const start = enabled && hasWindow ? document.startViewTransition : undefined;
|
|
64
|
+
if (typeof start !== "function")
|
|
65
|
+
return update();
|
|
66
|
+
start.call(document, () => {
|
|
67
|
+
update();
|
|
68
|
+
flush();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function scrollTo(x, y) {
|
|
72
|
+
try {
|
|
73
|
+
window.scrollTo(x, y);
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
45
76
|
function sync() {
|
|
46
77
|
if (!hasWindow)
|
|
47
78
|
return;
|
|
@@ -52,6 +83,18 @@ function sync() {
|
|
|
52
83
|
window.addEventListener("popstate", () => {
|
|
53
84
|
saveScroll(current.peek());
|
|
54
85
|
const next = readLocation();
|
|
86
|
+
if (transitionsDefault && typeof document.startViewTransition === "function") {
|
|
87
|
+
withTransition(true, () => {
|
|
88
|
+
current.set(next);
|
|
89
|
+
flush();
|
|
90
|
+
const saved = scrollPositions.get(locKey(next));
|
|
91
|
+
if (!saved && next.hash)
|
|
92
|
+
scrollToHash(next.hash);
|
|
93
|
+
else
|
|
94
|
+
scrollTo(saved?.[0] ?? 0, saved?.[1] ?? 0);
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
55
98
|
current.set(next);
|
|
56
99
|
restoreScroll(next);
|
|
57
100
|
});
|
|
@@ -106,16 +149,15 @@ function navigate(to, options = {}) {
|
|
|
106
149
|
else
|
|
107
150
|
history.pushState(options.state ?? null, "", url);
|
|
108
151
|
window.dispatchEvent(new CustomEvent("keeno:navigate", { detail: { url: url.href, state: options.state ?? null } }));
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
152
|
+
withTransition(options.transition ?? transitionsDefault, () => {
|
|
153
|
+
current.set(readLocation());
|
|
154
|
+
if (options.scroll === false)
|
|
155
|
+
return;
|
|
156
|
+
if (url.hash)
|
|
157
|
+
scrollToHash(url.hash);
|
|
158
|
+
else if (typeof window.scrollTo === "function")
|
|
159
|
+
scrollTo(0, 0);
|
|
160
|
+
});
|
|
119
161
|
}
|
|
120
162
|
function isExternalClick(e, a) {
|
|
121
163
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
@@ -133,7 +175,10 @@ function link(e) {
|
|
|
133
175
|
if (!underBase(url.pathname, base.peek()))
|
|
134
176
|
return;
|
|
135
177
|
e.preventDefault();
|
|
136
|
-
navigate(stripBase(url.pathname, base.peek()) + url.search + url.hash, {
|
|
178
|
+
navigate(stripBase(url.pathname, base.peek()) + url.search + url.hash, {
|
|
179
|
+
replace: a.hasAttribute("data-replace"),
|
|
180
|
+
transition: a.hasAttribute("data-transition") ? a.getAttribute("data-transition") !== "false" : undefined
|
|
181
|
+
});
|
|
137
182
|
}
|
|
138
183
|
var compiled = new WeakMap;
|
|
139
184
|
function compile(route) {
|
|
@@ -192,6 +237,7 @@ function Router(props) {
|
|
|
192
237
|
const loc = url != null ? new State(parseUrl(url)) : current;
|
|
193
238
|
if (url == null) {
|
|
194
239
|
base.set(routerBase);
|
|
240
|
+
transitionsDefault = !!untrack(() => props.viewTransition);
|
|
195
241
|
sync();
|
|
196
242
|
}
|
|
197
243
|
const outside = (l) => !underBase(l.pathname, routerBase);
|
|
@@ -227,15 +273,59 @@ function Router(props) {
|
|
|
227
273
|
};
|
|
228
274
|
provide(RouteContext, route);
|
|
229
275
|
provide(BaseContext, routerBase);
|
|
230
|
-
|
|
276
|
+
const withParams = (extra) => Object.defineProperty(extra, "params", { get: () => info.get().params, enumerable: true });
|
|
277
|
+
const chain = derived(() => {
|
|
231
278
|
const r = matched.get();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
279
|
+
return r ? [...r.layouts ?? [], r] : [];
|
|
280
|
+
});
|
|
281
|
+
const depth = untrack(() => Math.max(1, ...props.routes.map((r) => (r.layouts?.length ?? 0) + 1)));
|
|
282
|
+
const levels = [];
|
|
283
|
+
for (let i = 0;i < depth; i++) {
|
|
284
|
+
const entry = derived(() => chain.get()[i]);
|
|
285
|
+
levels.push(() => {
|
|
286
|
+
const e = entry.get();
|
|
287
|
+
if (!e)
|
|
288
|
+
return null;
|
|
289
|
+
if (typeof e === "function")
|
|
290
|
+
return untrack(() => createComponent(e, withParams({ children: levels[i + 1] })));
|
|
291
|
+
return untrack(() => renderPage(e));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
let resetError = null;
|
|
295
|
+
derived(() => info.get().path).onUpdate(() => {
|
|
296
|
+
const reset = resetError;
|
|
297
|
+
resetError = null;
|
|
298
|
+
reset?.();
|
|
299
|
+
});
|
|
300
|
+
const renderPage = (r) => {
|
|
301
|
+
const page = () => createComponent(r.component, withParams({}));
|
|
302
|
+
const errors = r.errors;
|
|
303
|
+
if (!errors || !Object.keys(errors).length)
|
|
304
|
+
return page();
|
|
305
|
+
return createComponent(ErrorBoundary, {
|
|
306
|
+
children: page,
|
|
307
|
+
fallback: (error, reset) => {
|
|
308
|
+
const status = errorStatus(error);
|
|
309
|
+
const ErrorPage = errors[status];
|
|
310
|
+
if (!ErrorPage)
|
|
311
|
+
throw error;
|
|
312
|
+
resetError = reset;
|
|
313
|
+
return createComponent(ErrorPage, withParams({
|
|
314
|
+
status,
|
|
315
|
+
error,
|
|
316
|
+
reset: () => {
|
|
317
|
+
resetError = null;
|
|
318
|
+
reset();
|
|
319
|
+
}
|
|
320
|
+
}));
|
|
237
321
|
}
|
|
238
|
-
})
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
const found = derived(() => !!matched.get());
|
|
325
|
+
return () => {
|
|
326
|
+
if (!found.get())
|
|
327
|
+
return props.fallback ? untrack(props.fallback) : null;
|
|
328
|
+
return levels[0]();
|
|
239
329
|
};
|
|
240
330
|
}
|
|
241
331
|
function useRoute() {
|
|
@@ -263,18 +353,108 @@ function Link(props) {
|
|
|
263
353
|
if (isExternalClick(e, a))
|
|
264
354
|
return;
|
|
265
355
|
e.preventDefault();
|
|
266
|
-
navigate(props.href, { replace: props.replace });
|
|
356
|
+
navigate(props.href, { replace: props.replace, transition: props.transition });
|
|
267
357
|
});
|
|
268
358
|
insert(a, () => props.children?.());
|
|
269
359
|
return a;
|
|
270
360
|
}
|
|
361
|
+
var ERROR_FILE = /^_([1-5]\d\d)$/;
|
|
362
|
+
function fileSegment(name) {
|
|
363
|
+
const m = /^\[(\.\.\.)?([^\]]+)\]$/.exec(name);
|
|
364
|
+
return m ? (m[1] ? "*" : ":") + m[2] : name;
|
|
365
|
+
}
|
|
366
|
+
function rank(seg) {
|
|
367
|
+
if (seg === undefined)
|
|
368
|
+
return 1;
|
|
369
|
+
if (seg.startsWith("*"))
|
|
370
|
+
return 0;
|
|
371
|
+
return seg.startsWith(":") ? 2 : 3;
|
|
372
|
+
}
|
|
373
|
+
function compareRoutes(a, b) {
|
|
374
|
+
const sa = a.split("/").filter(Boolean);
|
|
375
|
+
const sb = b.split("/").filter(Boolean);
|
|
376
|
+
for (let i = 0;i < Math.max(sa.length, sb.length); i++) {
|
|
377
|
+
const d = rank(sb[i]) - rank(sa[i]);
|
|
378
|
+
if (d)
|
|
379
|
+
return d;
|
|
380
|
+
}
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
function notFound() {
|
|
384
|
+
throw new HttpError(404, "Not found");
|
|
385
|
+
}
|
|
386
|
+
function pageRoutes(files, dir = "") {
|
|
387
|
+
const prefix = dir.replace(/\/+$/, "") + "/";
|
|
388
|
+
const dirs = new Map;
|
|
389
|
+
const getDir = (key) => {
|
|
390
|
+
let d = dirs.get(key);
|
|
391
|
+
if (!d)
|
|
392
|
+
dirs.set(key, d = { path: key ? key.split("/").map(fileSegment) : [], errors: {} });
|
|
393
|
+
return d;
|
|
394
|
+
};
|
|
395
|
+
const pages = [];
|
|
396
|
+
const component = (m) => typeof m === "function" ? lazy(m) : m.default;
|
|
397
|
+
for (const file of Object.keys(files).sort()) {
|
|
398
|
+
if (!file.startsWith(prefix) || !file.endsWith(".kn"))
|
|
399
|
+
continue;
|
|
400
|
+
const parts = file.slice(prefix.length, -".kn".length).split("/");
|
|
401
|
+
const name = parts.pop();
|
|
402
|
+
if (parts.some((p) => p.startsWith("_")))
|
|
403
|
+
continue;
|
|
404
|
+
const key = parts.join("/");
|
|
405
|
+
const d = getDir(key);
|
|
406
|
+
const mod = files[file];
|
|
407
|
+
const error = ERROR_FILE.exec(name);
|
|
408
|
+
if (name === "_layout")
|
|
409
|
+
d.layout = component(mod);
|
|
410
|
+
else if (error)
|
|
411
|
+
d.errors[+error[1]] = component(mod);
|
|
412
|
+
else if (name === "_all")
|
|
413
|
+
pages.push({ dir: key, path: [...d.path, "*"], component: component(mod) });
|
|
414
|
+
else if (name === "index")
|
|
415
|
+
pages.push({ dir: key, path: d.path, component: component(mod) });
|
|
416
|
+
else if (!name.startsWith("_"))
|
|
417
|
+
pages.push({ dir: key, path: [...d.path, fileSegment(name)], component: component(mod) });
|
|
418
|
+
}
|
|
419
|
+
const ancestors = (key) => {
|
|
420
|
+
const parts = key ? key.split("/") : [];
|
|
421
|
+
return [getDir(""), ...parts.map((_, i) => getDir(parts.slice(0, i + 1).join("/")))];
|
|
422
|
+
};
|
|
423
|
+
const scope = (key) => {
|
|
424
|
+
const chain = ancestors(key);
|
|
425
|
+
return {
|
|
426
|
+
layouts: chain.flatMap((d) => d.layout ? [d.layout] : []),
|
|
427
|
+
errors: Object.assign({}, ...chain.map((d) => d.errors))
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
const routes = pages.map((p) => ({
|
|
431
|
+
path: "/" + p.path.join("/"),
|
|
432
|
+
component: p.component,
|
|
433
|
+
...scope(p.dir)
|
|
434
|
+
}));
|
|
435
|
+
for (const [key, d] of [...dirs]) {
|
|
436
|
+
if (d.errors[404])
|
|
437
|
+
routes.push({ path: "/" + [...d.path, "*"].join("/"), component: notFound, fallback: true, ...scope(key) });
|
|
438
|
+
}
|
|
439
|
+
return routes.map((r, i) => ({ r, i })).sort((a, b) => compareRoutes(a.r.path, b.r.path) || Number(!!a.r.fallback) - Number(!!b.r.fallback) || a.i - b.i).map(({ r: { fallback, ...r } }) => r);
|
|
440
|
+
}
|
|
441
|
+
async function preloadRoute(routes, url) {
|
|
442
|
+
const path = parseUrl(url).pathname;
|
|
443
|
+
const route = routes.find((r) => matchPath(r, path));
|
|
444
|
+
await route?.component?.preload?.();
|
|
445
|
+
}
|
|
271
446
|
export {
|
|
447
|
+
HttpError,
|
|
272
448
|
Link,
|
|
273
449
|
Router,
|
|
450
|
+
errorStatus,
|
|
274
451
|
href,
|
|
452
|
+
httpError,
|
|
275
453
|
isActive,
|
|
276
454
|
link,
|
|
277
455
|
matchPath,
|
|
278
456
|
navigate,
|
|
457
|
+
pageRoutes,
|
|
458
|
+
preloadRoute,
|
|
279
459
|
useRoute
|
|
280
460
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflector/keeno-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Client-side router for Keeno (.kn) apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"prepack": "bun run build"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@inflector/keeno": ">=0.
|
|
42
|
+
"@inflector/keeno": ">=0.5.0 <1.0.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@inflector/keeno": "0.
|
|
45
|
+
"@inflector/keeno": "0.5.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|