@inflector/keeno-router 0.1.1 → 0.2.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 +54 -0
- package/dist/index.js +158 -8
- 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>>;
|
|
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;
|
|
16
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`. */
|
|
@@ -79,3 +110,26 @@ export interface LinkProps {
|
|
|
79
110
|
* while active. The scoped styles of the component that renders it apply to its `<a>`.
|
|
80
111
|
*/
|
|
81
112
|
export declare function Link(props: LinkProps): unknown;
|
|
113
|
+
/** A page module: loaded (`{ default }`) or a loader (`() => import('./page.kn')`). */
|
|
114
|
+
export type PageModule = {
|
|
115
|
+
default: Component<any>;
|
|
116
|
+
} | (() => Promise<{
|
|
117
|
+
default: Component<any>;
|
|
118
|
+
}>);
|
|
119
|
+
/**
|
|
120
|
+
* Routes from a directory of page files, as `import.meta.glob('/src/pages/**\/*.kn')` returns it
|
|
121
|
+
* (keys starting with `dir`):
|
|
122
|
+
*
|
|
123
|
+
* - `index.kn` is the directory's path, `about.kn` is `/about`, `[id].kn` is `/:id`.
|
|
124
|
+
* - `_layout.kn` wraps every page of its directory and the ones below it.
|
|
125
|
+
* - `_all.kn` matches every path below its directory that no other page does (`params['*']`).
|
|
126
|
+
* - `_404.kn` renders for paths below its directory that match nothing, and `_500.kn`, `_403.kn`…
|
|
127
|
+
* for errors with that status thrown by a page (`throw httpError(403)`; other errors are 500).
|
|
128
|
+
* - Other files starting with `_` are left out, so pages can keep their own components next to them.
|
|
129
|
+
*/
|
|
130
|
+
export declare function pageRoutes(files: Record<string, PageModule>, dir?: string): RouteDefinition[];
|
|
131
|
+
/**
|
|
132
|
+
* Load the page that matches `url` (an app path or full URL), if it loads on demand. Await it
|
|
133
|
+
* before `renderToString` or `hydrate`, so the page renders in the first pass.
|
|
134
|
+
*/
|
|
135
|
+
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, 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)
|
|
@@ -227,15 +243,59 @@ function Router(props) {
|
|
|
227
243
|
};
|
|
228
244
|
provide(RouteContext, route);
|
|
229
245
|
provide(BaseContext, routerBase);
|
|
230
|
-
|
|
246
|
+
const withParams = (extra) => Object.defineProperty(extra, "params", { get: () => info.get().params, enumerable: true });
|
|
247
|
+
const chain = derived(() => {
|
|
231
248
|
const r = matched.get();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
return r ? [...r.layouts ?? [], r] : [];
|
|
250
|
+
});
|
|
251
|
+
const depth = untrack(() => Math.max(1, ...props.routes.map((r) => (r.layouts?.length ?? 0) + 1)));
|
|
252
|
+
const levels = [];
|
|
253
|
+
for (let i = 0;i < depth; i++) {
|
|
254
|
+
const entry = derived(() => chain.get()[i]);
|
|
255
|
+
levels.push(() => {
|
|
256
|
+
const e = entry.get();
|
|
257
|
+
if (!e)
|
|
258
|
+
return null;
|
|
259
|
+
if (typeof e === "function")
|
|
260
|
+
return untrack(() => createComponent(e, withParams({ children: levels[i + 1] })));
|
|
261
|
+
return untrack(() => renderPage(e));
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
let resetError = null;
|
|
265
|
+
derived(() => info.get().path).onUpdate(() => {
|
|
266
|
+
const reset = resetError;
|
|
267
|
+
resetError = null;
|
|
268
|
+
reset?.();
|
|
269
|
+
});
|
|
270
|
+
const renderPage = (r) => {
|
|
271
|
+
const page = () => createComponent(r.component, withParams({}));
|
|
272
|
+
const errors = r.errors;
|
|
273
|
+
if (!errors || !Object.keys(errors).length)
|
|
274
|
+
return page();
|
|
275
|
+
return createComponent(ErrorBoundary, {
|
|
276
|
+
children: page,
|
|
277
|
+
fallback: (error, reset) => {
|
|
278
|
+
const status = errorStatus(error);
|
|
279
|
+
const ErrorPage = errors[status];
|
|
280
|
+
if (!ErrorPage)
|
|
281
|
+
throw error;
|
|
282
|
+
resetError = reset;
|
|
283
|
+
return createComponent(ErrorPage, withParams({
|
|
284
|
+
status,
|
|
285
|
+
error,
|
|
286
|
+
reset: () => {
|
|
287
|
+
resetError = null;
|
|
288
|
+
reset();
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
237
291
|
}
|
|
238
|
-
})
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
const found = derived(() => !!matched.get());
|
|
295
|
+
return () => {
|
|
296
|
+
if (!found.get())
|
|
297
|
+
return props.fallback ? untrack(props.fallback) : null;
|
|
298
|
+
return levels[0]();
|
|
239
299
|
};
|
|
240
300
|
}
|
|
241
301
|
function useRoute() {
|
|
@@ -268,13 +328,103 @@ function Link(props) {
|
|
|
268
328
|
insert(a, () => props.children?.());
|
|
269
329
|
return a;
|
|
270
330
|
}
|
|
331
|
+
var ERROR_FILE = /^_([1-5]\d\d)$/;
|
|
332
|
+
function fileSegment(name) {
|
|
333
|
+
const m = /^\[(\.\.\.)?([^\]]+)\]$/.exec(name);
|
|
334
|
+
return m ? (m[1] ? "*" : ":") + m[2] : name;
|
|
335
|
+
}
|
|
336
|
+
function rank(seg) {
|
|
337
|
+
if (seg === undefined)
|
|
338
|
+
return 1;
|
|
339
|
+
if (seg.startsWith("*"))
|
|
340
|
+
return 0;
|
|
341
|
+
return seg.startsWith(":") ? 2 : 3;
|
|
342
|
+
}
|
|
343
|
+
function compareRoutes(a, b) {
|
|
344
|
+
const sa = a.split("/").filter(Boolean);
|
|
345
|
+
const sb = b.split("/").filter(Boolean);
|
|
346
|
+
for (let i = 0;i < Math.max(sa.length, sb.length); i++) {
|
|
347
|
+
const d = rank(sb[i]) - rank(sa[i]);
|
|
348
|
+
if (d)
|
|
349
|
+
return d;
|
|
350
|
+
}
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
function notFound() {
|
|
354
|
+
throw new HttpError(404, "Not found");
|
|
355
|
+
}
|
|
356
|
+
function pageRoutes(files, dir = "") {
|
|
357
|
+
const prefix = dir.replace(/\/+$/, "") + "/";
|
|
358
|
+
const dirs = new Map;
|
|
359
|
+
const getDir = (key) => {
|
|
360
|
+
let d = dirs.get(key);
|
|
361
|
+
if (!d)
|
|
362
|
+
dirs.set(key, d = { path: key ? key.split("/").map(fileSegment) : [], errors: {} });
|
|
363
|
+
return d;
|
|
364
|
+
};
|
|
365
|
+
const pages = [];
|
|
366
|
+
const component = (m) => typeof m === "function" ? lazy(m) : m.default;
|
|
367
|
+
for (const file of Object.keys(files).sort()) {
|
|
368
|
+
if (!file.startsWith(prefix) || !file.endsWith(".kn"))
|
|
369
|
+
continue;
|
|
370
|
+
const parts = file.slice(prefix.length, -".kn".length).split("/");
|
|
371
|
+
const name = parts.pop();
|
|
372
|
+
if (parts.some((p) => p.startsWith("_")))
|
|
373
|
+
continue;
|
|
374
|
+
const key = parts.join("/");
|
|
375
|
+
const d = getDir(key);
|
|
376
|
+
const mod = files[file];
|
|
377
|
+
const error = ERROR_FILE.exec(name);
|
|
378
|
+
if (name === "_layout")
|
|
379
|
+
d.layout = component(mod);
|
|
380
|
+
else if (error)
|
|
381
|
+
d.errors[+error[1]] = component(mod);
|
|
382
|
+
else if (name === "_all")
|
|
383
|
+
pages.push({ dir: key, path: [...d.path, "*"], component: component(mod) });
|
|
384
|
+
else if (name === "index")
|
|
385
|
+
pages.push({ dir: key, path: d.path, component: component(mod) });
|
|
386
|
+
else if (!name.startsWith("_"))
|
|
387
|
+
pages.push({ dir: key, path: [...d.path, fileSegment(name)], component: component(mod) });
|
|
388
|
+
}
|
|
389
|
+
const ancestors = (key) => {
|
|
390
|
+
const parts = key ? key.split("/") : [];
|
|
391
|
+
return [getDir(""), ...parts.map((_, i) => getDir(parts.slice(0, i + 1).join("/")))];
|
|
392
|
+
};
|
|
393
|
+
const scope = (key) => {
|
|
394
|
+
const chain = ancestors(key);
|
|
395
|
+
return {
|
|
396
|
+
layouts: chain.flatMap((d) => d.layout ? [d.layout] : []),
|
|
397
|
+
errors: Object.assign({}, ...chain.map((d) => d.errors))
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
const routes = pages.map((p) => ({
|
|
401
|
+
path: "/" + p.path.join("/"),
|
|
402
|
+
component: p.component,
|
|
403
|
+
...scope(p.dir)
|
|
404
|
+
}));
|
|
405
|
+
for (const [key, d] of [...dirs]) {
|
|
406
|
+
if (d.errors[404])
|
|
407
|
+
routes.push({ path: "/" + [...d.path, "*"].join("/"), component: notFound, fallback: true, ...scope(key) });
|
|
408
|
+
}
|
|
409
|
+
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);
|
|
410
|
+
}
|
|
411
|
+
async function preloadRoute(routes, url) {
|
|
412
|
+
const path = parseUrl(url).pathname;
|
|
413
|
+
const route = routes.find((r) => matchPath(r, path));
|
|
414
|
+
await route?.component?.preload?.();
|
|
415
|
+
}
|
|
271
416
|
export {
|
|
417
|
+
HttpError,
|
|
272
418
|
Link,
|
|
273
419
|
Router,
|
|
420
|
+
errorStatus,
|
|
274
421
|
href,
|
|
422
|
+
httpError,
|
|
275
423
|
isActive,
|
|
276
424
|
link,
|
|
277
425
|
matchPath,
|
|
278
426
|
navigate,
|
|
427
|
+
pageRoutes,
|
|
428
|
+
preloadRoute,
|
|
279
429
|
useRoute
|
|
280
430
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflector/keeno-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.4.0 <1.0.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@inflector/keeno": "0.
|
|
45
|
+
"@inflector/keeno": "0.4.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|