@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 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
- current.set(readLocation());
110
- if (options.scroll === false)
111
- return;
112
- if (url.hash)
113
- scrollToHash(url.hash);
114
- else if (typeof window.scrollTo === "function") {
115
- try {
116
- window.scrollTo(0, 0);
117
- } catch {}
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, { replace: a.hasAttribute("data-replace") });
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
- return () => {
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
- if (!r)
233
- return props.fallback ? untrack(props.fallback) : null;
234
- return untrack(() => createComponent(r.component, {
235
- get params() {
236
- return info.get().params;
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.1.1",
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.3.0 <1.0.0"
42
+ "@inflector/keeno": ">=0.5.0 <1.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@inflector/keeno": "0.3.0"
45
+ "@inflector/keeno": "0.5.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"