@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 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
- return () => {
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
- if (!r)
233
- return props.fallback ? untrack(props.fallback) : null;
234
- return untrack(() => createComponent(r.component, {
235
- get params() {
236
- return info.get().params;
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.1.1",
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.3.0 <1.0.0"
42
+ "@inflector/keeno": ">=0.4.0 <1.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@inflector/keeno": "0.3.0"
45
+ "@inflector/keeno": "0.4.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"