@reely/router 0.0.0-stage → 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/README.md +300 -2
- package/dist/index.d.ts +131 -0
- package/dist/index.js +352 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -4
package/README.md
CHANGED
|
@@ -1,3 +1,301 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @reely/router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small typed router for any web app, with no framework: path patterns typed into their params, pages that load on demand, links and the back button taken over, and every page opened where the reader expects it, scrolled and focused. You decide what a page is and how it gets on screen. Built on [`@reely/signals`](https://www.npmjs.com/package/@reely/signals), so the path shown and the loading state are signals; tree-shakeable, so `href` alone ships 0.2 kB.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @reely/router
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## A vanilla app in one file
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { defineRoutes, startRouter } from '@reely/router';
|
|
13
|
+
|
|
14
|
+
const main = document.querySelector('main') ?? document.body;
|
|
15
|
+
|
|
16
|
+
const heading = (text: string): HTMLElement => Object.assign(document.createElement('h1'), { textContent: text });
|
|
17
|
+
|
|
18
|
+
const routes = defineRoutes({
|
|
19
|
+
'/': () => () => heading('Inbox'),
|
|
20
|
+
'/messages/:id':
|
|
21
|
+
({ id }) =>
|
|
22
|
+
() =>
|
|
23
|
+
heading(`Message ${id}`),
|
|
24
|
+
'/*rest':
|
|
25
|
+
({ rest }) =>
|
|
26
|
+
() =>
|
|
27
|
+
heading(`Nothing at /${rest}`),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
startRouter(routes, {
|
|
31
|
+
show: (page) => {
|
|
32
|
+
main.replaceChildren(page());
|
|
33
|
+
return main;
|
|
34
|
+
},
|
|
35
|
+
fail: (error) => () => heading(error.message),
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That is the whole setup. Links stay plain `<a href="/messages/42">`: the router takes over a plain click on any link of the site, and back, forward and a reload work as on a site of documents, without loading one.
|
|
40
|
+
|
|
41
|
+
- A **route** is a path pattern with the function that answers it. `:id` takes one segment, `*rest` the rest of the path; the params arrive decoded and typed from the pattern, so `({ topic })` on `'/messages/:id'` does not compile.
|
|
42
|
+
- A **page** is whatever your app shows: here a function that makes the view, but an element or `{ title, view }` work as well. Its type is inferred from what the routes answer, and `show` and `fail` are checked against it.
|
|
43
|
+
- `show` puts a page on screen and returns the element it is in (or its nodes); after a move, the page's `h1` gets the focus, so a screen reader starts reading the new page.
|
|
44
|
+
- `fail` gives the page for whatever went wrong: a route that throws or fails to load, an address no route answers, a page that throws in `show`.
|
|
45
|
+
|
|
46
|
+
## Routes
|
|
47
|
+
|
|
48
|
+
Routes are tried in order, and one that answers `undefined` passes the address on to the next, so a route can check its params and leave the rest to a page for unknown paths. A route may answer with a promise of its page, so the page behind `import()` ships in its own chunk and loads when it is first opened. The second argument is the query.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { defineRoutes } from '@reely/router';
|
|
52
|
+
|
|
53
|
+
interface Page {
|
|
54
|
+
title: string;
|
|
55
|
+
view: () => Node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Product {
|
|
59
|
+
name: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare const catalogue: Map<string, Product>;
|
|
63
|
+
declare const productPage: (product: Product) => Page;
|
|
64
|
+
declare const searchPage: (words: string, sort: string) => Page;
|
|
65
|
+
declare const loadSettings: () => Promise<{ settingsPage: Page }>;
|
|
66
|
+
declare const notFoundPage: (path: string) => Page;
|
|
67
|
+
|
|
68
|
+
export const routes = defineRoutes({
|
|
69
|
+
// an unknown slug passes on to the page for unknown paths
|
|
70
|
+
'/products/:slug': ({ slug }) => {
|
|
71
|
+
const product = catalogue.get(slug);
|
|
72
|
+
return product && productPage(product);
|
|
73
|
+
},
|
|
74
|
+
'/search': (_params, query) => searchPage(query.get('q') ?? '', query.get('sort') ?? 'relevance'),
|
|
75
|
+
// its own chunk, loaded when /settings is first opened
|
|
76
|
+
'/settings': () => loadSettings().then(({ settingsPage }) => settingsPage),
|
|
77
|
+
'/*rest': ({ rest }) => notFoundPage(`/${rest}`),
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`routes` is a plain function, `(address) => Promise<Page | undefined>`, so a test calls it without a browser: `await routes('/search?q=lamp')`.
|
|
82
|
+
|
|
83
|
+
## Links and moves
|
|
84
|
+
|
|
85
|
+
A route's pattern is its name. Keep the patterns in one object, and `href` fills one in for a link, typed and encoded, so a link and the route it opens share one path:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { href, navigate } from '@reely/router';
|
|
89
|
+
|
|
90
|
+
const paths = { message: '/messages/:id', folder: '/folders/*path' } as const;
|
|
91
|
+
|
|
92
|
+
const link = Object.assign(document.createElement('a'), {
|
|
93
|
+
href: href(paths.message, { id: '42' }), // '/messages/42'
|
|
94
|
+
textContent: 'Your order has shipped',
|
|
95
|
+
});
|
|
96
|
+
document.body.append(link);
|
|
97
|
+
|
|
98
|
+
href(paths.folder, { path: 'work/2026 plans' }); // '/folders/work/2026%20plans'
|
|
99
|
+
|
|
100
|
+
// after the form is sent, from code
|
|
101
|
+
navigate(href(paths.message, { id: '43' }));
|
|
102
|
+
// a redirect takes the place of the entry it leaves, so back does not return to it
|
|
103
|
+
navigate('/login?next=/account', { replace: true });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- The router takes over a plain left click on a link of this site. A click with a modifier key or another button, a link with `target="_blank"` or `download`, a link marked `rel="external"` (a file or a page the server answers), a link to another site, and a link to a place on the page shown stay the browser's.
|
|
107
|
+
- A new query (`?sort=price`) shows the page again; a new `#place` on the page shown only scrolls to it.
|
|
108
|
+
- `navigate` to the URL already shown adds no history entry.
|
|
109
|
+
|
|
110
|
+
A guard is a route that sends the reader elsewhere instead of answering:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { defineRoutes, navigate } from '@reely/router';
|
|
114
|
+
|
|
115
|
+
declare const signedIn: () => boolean;
|
|
116
|
+
declare const accountPage: () => Node;
|
|
117
|
+
declare const loginPage: (next: string) => () => Node;
|
|
118
|
+
|
|
119
|
+
export const routes = defineRoutes({
|
|
120
|
+
'/account': () => {
|
|
121
|
+
if (signedIn()) {
|
|
122
|
+
return accountPage;
|
|
123
|
+
}
|
|
124
|
+
navigate('/login?next=/account', { replace: true });
|
|
125
|
+
return undefined;
|
|
126
|
+
},
|
|
127
|
+
'/login': (_params, query) => loginPage(query.get('next') ?? '/'),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## The path shown, and the page loading
|
|
132
|
+
|
|
133
|
+
`startRouter` returns the router: `path()` and `loading()` read like signals, so an effect that reads them follows every move.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { effect } from '@reely/signals';
|
|
137
|
+
import { startRouter } from '@reely/router';
|
|
138
|
+
import type { Routes } from '@reely/router';
|
|
139
|
+
|
|
140
|
+
declare const routes: Routes<() => Node>;
|
|
141
|
+
|
|
142
|
+
const main = document.querySelector('main') ?? document.body;
|
|
143
|
+
const menu = Array.from(document.querySelectorAll<HTMLAnchorElement>('nav a'));
|
|
144
|
+
const bar = document.querySelector('progress') ?? document.createElement('progress');
|
|
145
|
+
|
|
146
|
+
const router = startRouter(routes, {
|
|
147
|
+
show: (page) => {
|
|
148
|
+
main.replaceChildren(page());
|
|
149
|
+
return main;
|
|
150
|
+
},
|
|
151
|
+
fail: (error) => () => document.createTextNode(error.message),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// the menu marks the page shown
|
|
155
|
+
effect(() => {
|
|
156
|
+
for (const link of menu) {
|
|
157
|
+
link.ariaCurrent = link.pathname === router.path() ? 'page' : null;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// the page shown stays until the next one has loaded: say that one is coming
|
|
162
|
+
effect(() => {
|
|
163
|
+
bar.hidden = !router.loading();
|
|
164
|
+
main.ariaBusy = String(router.loading());
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Only the latest move counts: a page still loading when the reader clicks another link is dropped. `currentPath()` and `pageLoading()` read the same for the browser's router from anywhere in the app, without the router at hand: a progress bar in the header needs only `pageLoading()`.
|
|
169
|
+
|
|
170
|
+
## Scroll and focus
|
|
171
|
+
|
|
172
|
+
The browser's router opens each page the way the reader expects:
|
|
173
|
+
|
|
174
|
+
- a new page at the top, or at the place its URL names (`/faq#returns`), with focus on its heading or on that place;
|
|
175
|
+
- back, forward and a reload where the reader had scrolled it, kept in the history entry's state;
|
|
176
|
+
- the browser's own scroll restoring stays off while the router runs, since a page that loads after the move would be scrolled before it is there.
|
|
177
|
+
|
|
178
|
+
## A router inside part of the page
|
|
179
|
+
|
|
180
|
+
A widget, a dialog or a help panel can have pages of its own and leave the address bar alone. `memoryHistory(start)` keeps the address in memory; `followLinks(root, navigate)` hands it the plain clicks on the links inside `root`, each read against its own address (so `href="7"` on `/orders/3` goes to `/orders/7`). It keeps no Back stack, so `replace` changes nothing there:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { defineRoutes, followLinks, href, memoryHistory, startRouter } from '@reely/router';
|
|
184
|
+
|
|
185
|
+
const panel = document.createElement('aside');
|
|
186
|
+
const paths = { topics: '/', topic: '/topics/:slug' } as const;
|
|
187
|
+
const text = (content: string): Node => document.createTextNode(content);
|
|
188
|
+
|
|
189
|
+
const help = memoryHistory(paths.topics);
|
|
190
|
+
followLinks(panel, help.navigate);
|
|
191
|
+
|
|
192
|
+
startRouter(
|
|
193
|
+
defineRoutes({
|
|
194
|
+
[paths.topics]: () => () => text('How can we help?'),
|
|
195
|
+
[paths.topic]:
|
|
196
|
+
({ slug }) =>
|
|
197
|
+
() =>
|
|
198
|
+
text(`About ${slug}`),
|
|
199
|
+
}),
|
|
200
|
+
{
|
|
201
|
+
history: help,
|
|
202
|
+
show: (page) => {
|
|
203
|
+
panel.replaceChildren(page());
|
|
204
|
+
return panel;
|
|
205
|
+
},
|
|
206
|
+
fail: (error) => () => text(error.message),
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
help.navigate(href(paths.topic, { slug: 'returns' })); // from code, the same way
|
|
211
|
+
console.log(help.path(), help.loading()); // the path shown and the next page loading, read like signals
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
After a move, the page's `h1` gets the focus, or its first heading of any level when it has none: a widget's pages start at `h2`.
|
|
215
|
+
|
|
216
|
+
## Moves from code
|
|
217
|
+
|
|
218
|
+
`navigate` is a move like a click, so keys, forms and timers move the router too:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { href, navigate } from '@reely/router';
|
|
222
|
+
|
|
223
|
+
const paths = { photo: '/photos/:id', album: '/photos' } as const;
|
|
224
|
+
const photos = ['harbour', 'meadow', 'dunes'];
|
|
225
|
+
|
|
226
|
+
declare const shownPhoto: () => string; // the id of the photo shown, from the router's path()
|
|
227
|
+
|
|
228
|
+
document.addEventListener('keydown', (event) => {
|
|
229
|
+
const next = photos[(photos.indexOf(shownPhoto()) + 1) % photos.length] ?? '';
|
|
230
|
+
if (event.key === 'ArrowRight') {
|
|
231
|
+
navigate(href(paths.photo, { id: next }));
|
|
232
|
+
} else if (event.key === 'Escape') {
|
|
233
|
+
navigate(href(paths.album));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const search = document.querySelector('form');
|
|
238
|
+
search?.addEventListener('submit', (event) => {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const words = new FormData(search).get('q');
|
|
241
|
+
navigate(`/search?${new URLSearchParams({ q: String(words ?? '') })}`);
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Stopping
|
|
246
|
+
|
|
247
|
+
`router.stop()` stops following links and moves and gives scrolling back to the browser. A router started under an owner of `@reely/signals` (a render of `@reely/dommy`, an effect run, `withOwner`) stops with it, and so does `followLinks`.
|
|
248
|
+
|
|
249
|
+
## With dommy
|
|
250
|
+
|
|
251
|
+
[`@reely/dommy/router`](https://www.npmjs.com/package/@reely/dommy) puts this router in a component, and re-exports the rest: `<Router routes={routes} catch={...} />` renders each page under its own owner and takes it down at the next move. Given a `history`, it runs inside a widget:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
import { defineRoutes, followLinks, href, memoryHistory, Router } from '@reely/dommy/router';
|
|
255
|
+
|
|
256
|
+
const paths = { topics: '/help', topic: '/help/:slug' } as const;
|
|
257
|
+
|
|
258
|
+
const routes = defineRoutes({
|
|
259
|
+
[paths.topics]: () => () => <h2>How can we help?</h2>,
|
|
260
|
+
[paths.topic]:
|
|
261
|
+
({ slug }) =>
|
|
262
|
+
() =>
|
|
263
|
+
<h2>About {slug}</h2>,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export const HelpWidget = (): Node => {
|
|
267
|
+
const help = memoryHistory(paths.topics);
|
|
268
|
+
const widget = (
|
|
269
|
+
<aside>
|
|
270
|
+
<nav>
|
|
271
|
+
{['returns', 'delivery'].map((slug) => (
|
|
272
|
+
<a
|
|
273
|
+
href={href(paths.topic, { slug })}
|
|
274
|
+
aria={{ ariaCurrent: () => (help.path() === href(paths.topic, { slug }) ? 'page' : null) }}
|
|
275
|
+
>
|
|
276
|
+
{slug}
|
|
277
|
+
</a>
|
|
278
|
+
))}
|
|
279
|
+
</nav>
|
|
280
|
+
<Router routes={routes} history={help} catch={(error) => <p role='alert'>{error.message}</p>} />
|
|
281
|
+
</aside>
|
|
282
|
+
);
|
|
283
|
+
followLinks(widget, help.navigate);
|
|
284
|
+
return widget;
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## API
|
|
289
|
+
|
|
290
|
+
- `defineRoutes(table)`: the routes of a table of path patterns, `(address) => Promise<Page | undefined>`.
|
|
291
|
+
- `startRouter(routes, { show, fail, history })`: shows the page of the current address and keeps following it; returns `{ path, loading, navigate, stop }`.
|
|
292
|
+
- `href(pattern, params)`: a URL from a pattern, typed and encoded.
|
|
293
|
+
- `navigate(to, { replace })`: goes to a URL of this site in the browser.
|
|
294
|
+
- `currentPath()` and `pageLoading()`: the path the browser's router shows and whether the next page is loading, read like signals.
|
|
295
|
+
- `memoryHistory(start)`: a history in memory, for a router inside part of the page; `path()`, `loading()` and `navigate()` on it.
|
|
296
|
+
- `followLinks(root, navigate)`: sends the plain clicks on the links inside `root` to `navigate`; returns the stop.
|
|
297
|
+
- Types: `Routes`, `RouteTable`, `ParamsOf`, `HrefParams`, `PageOf`, `Router`, `RouterOptions`, `RouterHistory`, `NavigateOptions`.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The path of the page the browser's router shows, read like a signal: a binding or effect that reads it
|
|
3
|
+
* follows every move, as a menu's `aria-current` does.
|
|
4
|
+
*/
|
|
5
|
+
export declare const currentPath: () => string;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Turns a table of path patterns into the app's routes: an address gets the page of the first route
|
|
9
|
+
* that matches its path and answers. `:name` takes one segment, `*name` the rest of the path; a route
|
|
10
|
+
* that answers nothing passes the address on to the next one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function defineRoutes<T>(table: RouteTable<T>): Routes<PageOf<T>>;
|
|
13
|
+
|
|
14
|
+
declare type Flat<T> = {
|
|
15
|
+
[K in keyof T]: T[K];
|
|
16
|
+
} & {};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sends the plain clicks on the links of this site inside `root` to `navigate`, with each link's own
|
|
20
|
+
* `href`: what a `memoryHistory` needs to follow the links of its part of the page. Returns the stop;
|
|
21
|
+
* called under an owner, it stops with it too.
|
|
22
|
+
*/
|
|
23
|
+
export declare const followLinks: (root: EventTarget, navigate: (to: string) => void) => (() => void);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The URL of a route: its pattern with the params filled in and encoded, typed from the pattern,
|
|
27
|
+
* so a link and the route it opens share one path. `*rest` keeps its slashes.
|
|
28
|
+
*/
|
|
29
|
+
export declare function href<P extends string>(pattern: P, ...params: HrefParams<P>): string;
|
|
30
|
+
|
|
31
|
+
/** What `href` takes after the pattern: its params, or nothing for a pattern without any. */
|
|
32
|
+
export declare type HrefParams<P extends string> = keyof ParamsOf<P> extends never ? [] : [params: ParamsOf<P>];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A history kept in memory, for a router inside part of the page (a widget, a dialog, a demo): it
|
|
36
|
+
* starts at `start` and leaves the document's URL alone. It follows the links `followLinks` gives it.
|
|
37
|
+
*/
|
|
38
|
+
export declare const memoryHistory: (start?: string) => RouterHistory;
|
|
39
|
+
|
|
40
|
+
/** Goes to a URL of this site without loading the document; the router shows its page. */
|
|
41
|
+
export declare const navigate: (to: string | URL, { replace }?: NavigateOptions) => void;
|
|
42
|
+
|
|
43
|
+
export declare interface NavigateOptions {
|
|
44
|
+
/** Takes the place of the current history entry, as a redirect does, instead of adding one. */
|
|
45
|
+
replace?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
declare type NoParams = Record<never, string>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the browser's router is loading the next page, read like a signal: the page shown stays
|
|
52
|
+
* until it has loaded, so a progress bar anywhere in the app says one is coming.
|
|
53
|
+
*/
|
|
54
|
+
export declare const pageLoading: () => boolean;
|
|
55
|
+
|
|
56
|
+
/** The pages a route table answers with, loaded. */
|
|
57
|
+
export declare type PageOf<T> = NonNullable<Awaited<T[keyof T]>>;
|
|
58
|
+
|
|
59
|
+
/** The params a path pattern names: `:name` for one segment, `*name` for the rest of the path. */
|
|
60
|
+
export declare type ParamsOf<P extends string> = Flat<P extends `${string}:${infer Name}/${infer Rest}` ? Record<Name, string> & ParamsOf<`/${Rest}`> : P extends `${string}:${infer Name}` ? Record<Name, string> : P extends `${string}*${infer Name}` ? Name extends '' ? NoParams : Record<Name, string> : NoParams>;
|
|
61
|
+
|
|
62
|
+
export declare interface Router {
|
|
63
|
+
/** The path of the page shown, read like a signal: an effect that reads it follows every move. */
|
|
64
|
+
path: () => string;
|
|
65
|
+
/** Whether a page is loading, read like a signal; the page shown stays until it has loaded. */
|
|
66
|
+
loading: () => boolean;
|
|
67
|
+
/** Goes to an address of this router, as a click on a link to it does. */
|
|
68
|
+
navigate: (to: string | URL, options?: NavigateOptions) => void;
|
|
69
|
+
/** Stops following moves; a router started under an owner stops with it too. */
|
|
70
|
+
stop: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where a router's addresses come from and how it moves between them: the browser's history, or
|
|
75
|
+
* `memoryHistory` for a router inside a part of the page.
|
|
76
|
+
*/
|
|
77
|
+
export declare interface RouterHistory {
|
|
78
|
+
/** The address now. */
|
|
79
|
+
url: () => URL;
|
|
80
|
+
/** The path of the page shown, read like a signal. */
|
|
81
|
+
path: () => string;
|
|
82
|
+
/** Whether the next page is loading, read like a signal. */
|
|
83
|
+
loading: () => boolean;
|
|
84
|
+
/** The router says a page for the address now starts loading, or that loading is over. */
|
|
85
|
+
loads: (loading: boolean) => void;
|
|
86
|
+
/** Goes to an address, as a click on a link to it does. */
|
|
87
|
+
navigate: (to: string | URL, options?: NavigateOptions) => void;
|
|
88
|
+
/** Calls `moved` after each move until the stop it returns. */
|
|
89
|
+
follow: (moved: () => void) => () => void;
|
|
90
|
+
/** Takes the address now for the page about to be shown, so the page reads its path while it renders. */
|
|
91
|
+
showing: () => void;
|
|
92
|
+
/** Brings the reader to the page just shown; `moved` is false for the first page. */
|
|
93
|
+
arrive: (shown: Shown, moved: boolean) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export declare interface RouterOptions<Page> {
|
|
97
|
+
/** Puts a page on screen; returns the element it is shown in, or its nodes, whose heading a move focuses. */
|
|
98
|
+
show: (page: Page) => Shown;
|
|
99
|
+
/** The page for what went wrong: a route that fails or fails to load, an address no route answers, a page that fails to show. */
|
|
100
|
+
fail: (error: Error) => Page;
|
|
101
|
+
/** Where the addresses come from: the browser's history when left out, or a `memoryHistory`. */
|
|
102
|
+
history?: RouterHistory;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The routes of an app as one function: the page for an address (a path and its query), or
|
|
107
|
+
* undefined when no route answers it. A page is whatever the app shows: a render function, an
|
|
108
|
+
* element, an object with a title.
|
|
109
|
+
*/
|
|
110
|
+
export declare type Routes<Page> = (address: string) => Promise<Page | undefined>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A route table: each path pattern with the function that answers it from the params the pattern
|
|
114
|
+
* names and the address's query. An answer is a page, a promise of one (`import()`), or nothing to
|
|
115
|
+
* pass the address on to the next route.
|
|
116
|
+
*/
|
|
117
|
+
export declare type RouteTable<T> = {
|
|
118
|
+
[P in keyof T & string]: (params: ParamsOf<P>, query: URLSearchParams) => T[P];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** What `show` returns: the element a page is shown in, or the nodes it put on screen. */
|
|
122
|
+
declare type Shown = Node | readonly Node[];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Shows the page of the current address and keeps following it: a click on a link of this site, the
|
|
126
|
+
* browser's back and forward, and `navigate` show the next page without loading the document. The
|
|
127
|
+
* page shown stays until the next one has loaded; only the latest move counts.
|
|
128
|
+
*/
|
|
129
|
+
export declare const startRouter: <Page>(routes: Routes<Page>, { show, fail, history }: RouterOptions<NoInfer<Page>>) => Router;
|
|
130
|
+
|
|
131
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { batch, onCleanup, signal } from "@reely/signals";
|
|
2
|
+
import { forEachSettled, hasSome, isPlainObject, isSomeFunction, reportUncaught, toErrorWithMessage } from "@reely/basics";
|
|
3
|
+
//#region src/lib/router.current.ts
|
|
4
|
+
var shown;
|
|
5
|
+
var shownPath = () => shown ??= signal(location.pathname);
|
|
6
|
+
/**
|
|
7
|
+
* The path of the page the browser's router shows, read like a signal: a binding or effect that reads it
|
|
8
|
+
* follows every move, as a menu's `aria-current` does.
|
|
9
|
+
*/
|
|
10
|
+
var currentPath = () => shownPath().value;
|
|
11
|
+
var showPath = (path) => {
|
|
12
|
+
shownPath().value = path;
|
|
13
|
+
};
|
|
14
|
+
var loading;
|
|
15
|
+
var loadingPage = () => loading ??= signal(false);
|
|
16
|
+
/**
|
|
17
|
+
* Whether the browser's router is loading the next page, read like a signal: the page shown stays
|
|
18
|
+
* until it has loaded, so a progress bar anywhere in the app says one is coming.
|
|
19
|
+
*/
|
|
20
|
+
var pageLoading = () => loadingPage().value;
|
|
21
|
+
var showLoading = (value) => {
|
|
22
|
+
loadingPage().value = value;
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/lib/router.href.ts
|
|
26
|
+
function href(pattern, params = {}) {
|
|
27
|
+
return pattern.split("/").map((part) => {
|
|
28
|
+
const value = params[part.slice(1)] ?? "";
|
|
29
|
+
if (part.startsWith(":")) return encodeURIComponent(value);
|
|
30
|
+
return part.startsWith("*") ? value.split("/").map(encodeURIComponent).join("/") : part;
|
|
31
|
+
}).join("/");
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region ../utils/dist/lib/fp/identity.js
|
|
35
|
+
function identity(v) {
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region ../utils/dist/lib/objects/isNil.js
|
|
40
|
+
function isNil(value) {
|
|
41
|
+
return value === null || value === void 0;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../utils/dist/lib/objects/isInstanceOf.js
|
|
45
|
+
function isInstanceOf(instanceType, instance) {
|
|
46
|
+
return instance instanceof instanceType;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region ../utils/dist/lib/objects/isValidRecordKey.js
|
|
50
|
+
function isValidRecordKey(source) {
|
|
51
|
+
return typeof source === "string" || typeof source === "number" || typeof source === "symbol";
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region ../utils/dist/lib/objects/isNumber.js
|
|
55
|
+
function isNumber(source) {
|
|
56
|
+
return typeof source === "number" || source instanceof Number;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region ../utils/dist/lib/objects/hasProperty.js
|
|
60
|
+
function hasProperty(property, source) {
|
|
61
|
+
return isValidRecordKey(property) && (typeof source === "object" || isSomeFunction(source)) && hasSome(source) && property in source;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/lib/router.links.ts
|
|
65
|
+
var linkOf = (target) => isInstanceOf(Element, target) ? target.closest("a") : null;
|
|
66
|
+
var isPlainClick = (event) => event.button === 0 && !event.defaultPrevented && !(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);
|
|
67
|
+
var opensHere = (link) => (link.target === "" || link.target === "_self") && !link.hasAttribute("download") && !link.relList.contains("external") && link.origin === location.origin;
|
|
68
|
+
var takeOverLinks = (root, follow, signal) => {
|
|
69
|
+
root.addEventListener("click", (event) => {
|
|
70
|
+
const link = isInstanceOf(MouseEvent, event) && isPlainClick(event) ? linkOf(event.target) : null;
|
|
71
|
+
if (!isNil(link) && opensHere(link) && follow(link)) event.preventDefault();
|
|
72
|
+
}, { signal });
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Sends the plain clicks on the links of this site inside `root` to `navigate`, with each link's own
|
|
76
|
+
* `href`: what a `memoryHistory` needs to follow the links of its part of the page. Returns the stop;
|
|
77
|
+
* called under an owner, it stops with it too.
|
|
78
|
+
*/
|
|
79
|
+
var followLinks = (root, navigate) => {
|
|
80
|
+
const listening = new AbortController();
|
|
81
|
+
const stop = () => listening.abort();
|
|
82
|
+
takeOverLinks(root, (link) => {
|
|
83
|
+
navigate(link.getAttribute("href") ?? "");
|
|
84
|
+
return true;
|
|
85
|
+
}, listening.signal);
|
|
86
|
+
onCleanup(stop);
|
|
87
|
+
return stop;
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/lib/router.focus.ts
|
|
91
|
+
var firstOf = (shown, selector) => {
|
|
92
|
+
for (const node of isInstanceOf(Node, shown) ? [shown] : shown) {
|
|
93
|
+
const found = isInstanceOf(Element, node) && !node.matches(selector) ? node.querySelector(selector) : node;
|
|
94
|
+
if (isInstanceOf(Element, found) && found.matches(selector)) return found;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The heading of a page shown, not one the app shows around it: its `h1`, or its first heading of any
|
|
100
|
+
* level when it has none (a widget's pages start at `h2`).
|
|
101
|
+
*/
|
|
102
|
+
var headingIn = (shown) => firstOf(shown, "h1") ?? firstOf(shown, "h1, h2, h3, h4, h5, h6");
|
|
103
|
+
var focusOn = (target) => {
|
|
104
|
+
if (isInstanceOf(HTMLElement, target)) {
|
|
105
|
+
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
|
106
|
+
target.focus({ preventScroll: true });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/lib/router.history.ts
|
|
111
|
+
var pageAddress = ({ pathname, search }) => `${pathname}${search}`;
|
|
112
|
+
/** Any address parses against it where only its path and query matter, never its origin. */
|
|
113
|
+
var anyOrigin = "https://reely.invalid";
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/lib/router.memory.ts
|
|
116
|
+
/**
|
|
117
|
+
* A history kept in memory, for a router inside part of the page (a widget, a dialog, a demo): it
|
|
118
|
+
* starts at `start` and leaves the document's URL alone. It follows the links `followLinks` gives it.
|
|
119
|
+
*/
|
|
120
|
+
var memoryHistory = (start = "/") => {
|
|
121
|
+
let now = new URL(start, anyOrigin);
|
|
122
|
+
const shown = signal(now.pathname);
|
|
123
|
+
const loading = signal(false);
|
|
124
|
+
const movers = /* @__PURE__ */ new Set();
|
|
125
|
+
return {
|
|
126
|
+
url: () => new URL(now),
|
|
127
|
+
path: () => shown.value,
|
|
128
|
+
loading: () => loading.value,
|
|
129
|
+
loads: (value) => {
|
|
130
|
+
loading.value = value;
|
|
131
|
+
},
|
|
132
|
+
navigate: (to) => {
|
|
133
|
+
const next = new URL(to, now);
|
|
134
|
+
if (next.href !== now.href) {
|
|
135
|
+
now = next;
|
|
136
|
+
forEachSettled(movers, (moved) => moved());
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
follow: (moved) => {
|
|
140
|
+
movers.add(moved);
|
|
141
|
+
return () => void movers.delete(moved);
|
|
142
|
+
},
|
|
143
|
+
showing: () => {
|
|
144
|
+
shown.value = now.pathname;
|
|
145
|
+
},
|
|
146
|
+
arrive: (page, moved) => {
|
|
147
|
+
if (moved) focusOn(headingIn(page));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/lib/router.navigate.ts
|
|
153
|
+
var leavingEvent = "reely:leaving";
|
|
154
|
+
var navigationEvent = "reely:navigate";
|
|
155
|
+
/** Goes to a URL of this site without loading the document; the router shows its page. */
|
|
156
|
+
var navigate = (to, { replace = false } = {}) => {
|
|
157
|
+
const url = new URL(to, location.href);
|
|
158
|
+
if (url.href === location.href) return;
|
|
159
|
+
if (replace) history.replaceState(null, "", url);
|
|
160
|
+
else {
|
|
161
|
+
window.dispatchEvent(new Event(leavingEvent));
|
|
162
|
+
history.pushState(null, "", url);
|
|
163
|
+
}
|
|
164
|
+
window.dispatchEvent(new Event(navigationEvent));
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/lib/router.decode.ts
|
|
168
|
+
var decode = (value) => {
|
|
169
|
+
try {
|
|
170
|
+
return decodeURIComponent(value);
|
|
171
|
+
} catch {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/lib/router.routes.ts
|
|
177
|
+
var segmentsOf = (path) => path.split("/").filter((segment) => segment !== "");
|
|
178
|
+
var matchSegments = (pattern, segments) => {
|
|
179
|
+
const params = {};
|
|
180
|
+
for (const [index, part] of pattern.entries()) {
|
|
181
|
+
if (part.startsWith("*")) {
|
|
182
|
+
params[part.slice(1)] = decode(segments.slice(index).join("/"));
|
|
183
|
+
return params;
|
|
184
|
+
}
|
|
185
|
+
const segment = segments[index];
|
|
186
|
+
if (isNil(segment) || !part.startsWith(":") && part !== segment) return;
|
|
187
|
+
if (part.startsWith(":")) params[part.slice(1)] = decode(segment);
|
|
188
|
+
}
|
|
189
|
+
return segments.length === pattern.length ? params : void 0;
|
|
190
|
+
};
|
|
191
|
+
function defineRoutes(table) {
|
|
192
|
+
const routes = Object.entries(table).map(([pattern, answer]) => ({
|
|
193
|
+
pattern: segmentsOf(pattern),
|
|
194
|
+
answer
|
|
195
|
+
}));
|
|
196
|
+
return async (address) => {
|
|
197
|
+
const { pathname, searchParams } = new URL(address, anyOrigin);
|
|
198
|
+
const segments = segmentsOf(pathname);
|
|
199
|
+
for (const { pattern, answer } of routes) {
|
|
200
|
+
const params = matchSegments(pattern, segments);
|
|
201
|
+
const page = hasSome(params) ? await answer(params, searchParams) : void 0;
|
|
202
|
+
if (hasSome(page)) return page;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/lib/router.scroll.ts
|
|
208
|
+
var scrollKey = "reelyScroll";
|
|
209
|
+
/** Where the reader had scrolled the page of the current history entry, kept in its state for a reload too. */
|
|
210
|
+
var keptScroll = () => {
|
|
211
|
+
const { state } = history;
|
|
212
|
+
const scrolled = hasProperty(scrollKey, state) ? state[scrollKey] : void 0;
|
|
213
|
+
return isNumber(scrolled) ? scrolled : void 0;
|
|
214
|
+
};
|
|
215
|
+
/** Keeps where the page of the current history entry is scrolled, and what else its state held. */
|
|
216
|
+
var keepScroll = () => {
|
|
217
|
+
history.replaceState({
|
|
218
|
+
...isPlainObject(history.state) ? history.state : {},
|
|
219
|
+
[scrollKey]: window.scrollY
|
|
220
|
+
}, "");
|
|
221
|
+
};
|
|
222
|
+
/** The element the hash of the location names. */
|
|
223
|
+
var placeInUrl = () => location.hash === "" ? null : document.getElementById(decode(location.hash.slice(1)));
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/lib/router.browser.ts
|
|
226
|
+
/**
|
|
227
|
+
* The browser's own history: links of this site anywhere in the document, back and forward, and
|
|
228
|
+
* `navigate`; each page opens where the reader had scrolled it, at the place its URL names, or at
|
|
229
|
+
* the top.
|
|
230
|
+
*/
|
|
231
|
+
var browserHistory = () => {
|
|
232
|
+
let shown = "";
|
|
233
|
+
return {
|
|
234
|
+
url: () => new URL(location.href),
|
|
235
|
+
path: currentPath,
|
|
236
|
+
loading: pageLoading,
|
|
237
|
+
loads: showLoading,
|
|
238
|
+
navigate,
|
|
239
|
+
follow: (moved) => {
|
|
240
|
+
const listening = new AbortController();
|
|
241
|
+
const { signal } = listening;
|
|
242
|
+
const { scrollRestoration } = history;
|
|
243
|
+
let saving;
|
|
244
|
+
const saveScroll = () => {
|
|
245
|
+
if (pageAddress(location) === shown) keepScroll();
|
|
246
|
+
};
|
|
247
|
+
takeOverLinks(document, (link) => {
|
|
248
|
+
if (pageAddress(link) === shown && link.hash !== "") return false;
|
|
249
|
+
navigate(link.href);
|
|
250
|
+
return true;
|
|
251
|
+
}, signal);
|
|
252
|
+
window.addEventListener("popstate", moved, { signal });
|
|
253
|
+
window.addEventListener(navigationEvent, moved, { signal });
|
|
254
|
+
window.addEventListener("scroll", () => {
|
|
255
|
+
clearTimeout(saving);
|
|
256
|
+
saving = setTimeout(saveScroll, 100);
|
|
257
|
+
}, {
|
|
258
|
+
signal,
|
|
259
|
+
passive: true
|
|
260
|
+
});
|
|
261
|
+
window.addEventListener("pagehide", saveScroll, { signal });
|
|
262
|
+
window.addEventListener(leavingEvent, saveScroll, { signal });
|
|
263
|
+
history.scrollRestoration = "manual";
|
|
264
|
+
return () => {
|
|
265
|
+
listening.abort();
|
|
266
|
+
clearTimeout(saving);
|
|
267
|
+
history.scrollRestoration = scrollRestoration;
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
showing: () => {
|
|
271
|
+
shown = pageAddress(location);
|
|
272
|
+
showPath(location.pathname);
|
|
273
|
+
},
|
|
274
|
+
arrive: (page, moved) => {
|
|
275
|
+
const place = placeInUrl();
|
|
276
|
+
const scrolled = keptScroll();
|
|
277
|
+
if (hasSome(scrolled)) window.scrollTo(0, scrolled);
|
|
278
|
+
else if (hasSome(place)) place.scrollIntoView();
|
|
279
|
+
else if (moved) window.scrollTo(0, 0);
|
|
280
|
+
focusOn(place ?? (moved ? headingIn(page) : null));
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/lib/router.start.ts
|
|
286
|
+
/**
|
|
287
|
+
* Shows the page of the current address and keeps following it: a click on a link of this site, the
|
|
288
|
+
* browser's back and forward, and `navigate` show the next page without loading the document. The
|
|
289
|
+
* page shown stays until the next one has loaded; only the latest move counts.
|
|
290
|
+
*/
|
|
291
|
+
var startRouter = (routes, { show, fail, history = browserHistory() }) => {
|
|
292
|
+
let latest = 0;
|
|
293
|
+
let requested = pageAddress(history.url());
|
|
294
|
+
let shown = "";
|
|
295
|
+
let onScreen = [];
|
|
296
|
+
const failure = (error) => fail(toErrorWithMessage(error));
|
|
297
|
+
const display = (page, url, moved) => {
|
|
298
|
+
shown = pageAddress(url);
|
|
299
|
+
batch(() => {
|
|
300
|
+
history.loads(false);
|
|
301
|
+
history.showing();
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
onScreen = show(page);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
onScreen = show(failure(error));
|
|
307
|
+
}
|
|
308
|
+
history.arrive(onScreen, moved);
|
|
309
|
+
};
|
|
310
|
+
const load = (moved) => {
|
|
311
|
+
const turn = ++latest;
|
|
312
|
+
const url = history.url();
|
|
313
|
+
history.loads(true);
|
|
314
|
+
Promise.resolve().then(() => routes(pageAddress(url))).then((page) => page ?? Promise.reject(/* @__PURE__ */ new Error(`No route answers ${url.pathname}`))).then(identity, failure).then((page) => {
|
|
315
|
+
if (turn === latest) display(page, url, moved);
|
|
316
|
+
}).catch((error) => {
|
|
317
|
+
if (turn === latest) history.loads(false);
|
|
318
|
+
reportUncaught(error);
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
const move = () => {
|
|
322
|
+
const address = pageAddress(history.url());
|
|
323
|
+
if (address === shown) {
|
|
324
|
+
latest += 1;
|
|
325
|
+
requested = address;
|
|
326
|
+
history.loads(false);
|
|
327
|
+
history.arrive(onScreen, true);
|
|
328
|
+
} else if (address !== requested) {
|
|
329
|
+
requested = address;
|
|
330
|
+
load(true);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
let unfollow = history.follow(move);
|
|
334
|
+
const stop = () => {
|
|
335
|
+
unfollow?.();
|
|
336
|
+
unfollow = null;
|
|
337
|
+
latest += 1;
|
|
338
|
+
history.loads(false);
|
|
339
|
+
};
|
|
340
|
+
onCleanup(stop);
|
|
341
|
+
load(false);
|
|
342
|
+
return {
|
|
343
|
+
path: history.path,
|
|
344
|
+
loading: history.loading,
|
|
345
|
+
navigate: history.navigate,
|
|
346
|
+
stop
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
//#endregion
|
|
350
|
+
export { currentPath, defineRoutes, followLinks, href, memoryHistory, navigate, pageLoading, startRouter };
|
|
351
|
+
|
|
352
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/lib/router.current.ts","../src/lib/router.href.ts","../../utils/dist/lib/fp/identity.js","../../utils/dist/lib/objects/isNil.js","../../utils/dist/lib/objects/isInstanceOf.js","../../utils/dist/lib/objects/isValidRecordKey.js","../../utils/dist/lib/objects/isNumber.js","../../utils/dist/lib/objects/hasProperty.js","../src/lib/router.links.ts","../src/lib/router.focus.ts","../src/lib/router.history.ts","../src/lib/router.memory.ts","../src/lib/router.navigate.ts","../src/lib/router.decode.ts","../src/lib/router.routes.ts","../src/lib/router.scroll.ts","../src/lib/router.browser.ts","../src/lib/router.start.ts"],"sourcesContent":["import { signal } from '@reely/signals';\nimport type { Signal } from '@reely/signals';\n\n// one location per document, so one path shown; the signal is made on first use\nlet shown: Signal<string> | undefined;\n\nconst shownPath = (): Signal<string> => (shown ??= signal(location.pathname));\n\n/**\n * The path of the page the browser's router shows, read like a signal: a binding or effect that reads it\n * follows every move, as a menu's `aria-current` does.\n */\nexport const currentPath = (): string => shownPath().value;\n\n// the Router tells `currentPath` the path of each page it shows\nexport const showPath = (path: string): void => {\n shownPath().value = path;\n};\n\n// and one page loading at a time\nlet loading: Signal<boolean> | undefined;\n\nconst loadingPage = (): Signal<boolean> => (loading ??= signal(false));\n\n/**\n * Whether the browser's router is loading the next page, read like a signal: the page shown stays\n * until it has loaded, so a progress bar anywhere in the app says one is coming.\n */\nexport const pageLoading = (): boolean => loadingPage().value;\n\nexport const showLoading = (value: boolean): void => {\n loadingPage().value = value;\n};\n","import type { ParamsOf } from './router.types';\n\n/** What `href` takes after the pattern: its params, or nothing for a pattern without any. */\nexport type HrefParams<P extends string> = keyof ParamsOf<P> extends never ? [] : [params: ParamsOf<P>];\n\n/**\n * The URL of a route: its pattern with the params filled in and encoded, typed from the pattern,\n * so a link and the route it opens share one path. `*rest` keeps its slashes.\n */\nexport function href<P extends string>(pattern: P, ...params: HrefParams<P>): string;\nexport function href(pattern: string, params: Readonly<Record<string, string>> = {}): string {\n return pattern\n .split('/')\n .map((part) => {\n const value = params[part.slice(1)] ?? '';\n if (part.startsWith(':')) {\n return encodeURIComponent(value);\n }\n return part.startsWith('*') ? value.split('/').map(encodeURIComponent).join('/') : part;\n })\n .join('/');\n}\n","export default function identity(v) {\n return v;\n}\n","export default function isNil(value) {\n return value === null || value === undefined;\n}\n","export default function isInstanceOf(instanceType, instance) {\n return instance instanceof instanceType;\n}\n","export default function isValidRecordKey(source) {\n return typeof source === 'string' || typeof source === 'number' || typeof source === 'symbol';\n}\n","export default function isNumber(source) {\n return typeof source === 'number' || source instanceof Number;\n}\n","import { hasSome, isSomeFunction } from '@reely/basics';\nimport isValidRecordKey from './isValidRecordKey';\nexport function hasProperty(property, source) {\n return (isValidRecordKey(property) &&\n (typeof source === 'object' || isSomeFunction(source)) &&\n hasSome(source) &&\n property in source);\n}\n","import { onCleanup } from '@reely/signals';\nimport { isInstanceOf, isNil } from '@reely/utils';\nimport type { Nullable } from '@reely/utils';\n\nconst linkOf = (target: Nullable<EventTarget>): Nullable<HTMLAnchorElement> =>\n isInstanceOf(Element, target) ? target.closest('a') : null;\n\n// a primary-button click without modifiers: the others open tabs and windows\nconst isPlainClick = (event: MouseEvent): boolean =>\n event.button === 0 && !event.defaultPrevented && !(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);\n\n// `rel=\"external\"` marks a link of this site the server answers (a file, a page of another app)\nconst opensHere = (link: HTMLAnchorElement): boolean =>\n (link.target === '' || link.target === '_self') &&\n !link.hasAttribute('download') &&\n !link.relList.contains('external') &&\n link.origin === location.origin;\n\n// takes over the plain clicks on the links of this site inside `root` that `follow` takes: it\n// answers whether it went to the link's page, or leaves the click to the browser\nexport const takeOverLinks = (\n root: EventTarget,\n follow: (link: HTMLAnchorElement) => boolean,\n signal: AbortSignal\n): void => {\n root.addEventListener(\n 'click',\n (event) => {\n const link = isInstanceOf(MouseEvent, event) && isPlainClick(event) ? linkOf(event.target) : null;\n if (!isNil(link) && opensHere(link) && follow(link)) {\n event.preventDefault();\n }\n },\n { signal }\n );\n};\n\n/**\n * Sends the plain clicks on the links of this site inside `root` to `navigate`, with each link's own\n * `href`: what a `memoryHistory` needs to follow the links of its part of the page. Returns the stop;\n * called under an owner, it stops with it too.\n */\nexport const followLinks = (root: EventTarget, navigate: (to: string) => void): (() => void) => {\n const listening = new AbortController();\n const stop = (): void => listening.abort();\n takeOverLinks(\n root,\n (link) => {\n navigate(link.getAttribute('href') ?? '');\n return true;\n },\n listening.signal\n );\n onCleanup(stop);\n return stop;\n};\n","import { isInstanceOf } from '@reely/utils';\nimport type { Nullable } from '@reely/utils';\n\nimport type { Shown } from './router.history';\n\nconst firstOf = (shown: Shown, selector: string): Nullable<Element> => {\n for (const node of isInstanceOf(Node, shown) ? [shown] : shown) {\n const found = isInstanceOf(Element, node) && !node.matches(selector) ? node.querySelector(selector) : node;\n if (isInstanceOf(Element, found) && found.matches(selector)) {\n return found;\n }\n }\n return null;\n};\n\n/**\n * The heading of a page shown, not one the app shows around it: its `h1`, or its first heading of any\n * level when it has none (a widget's pages start at `h2`).\n */\nexport const headingIn = (shown: Shown): Nullable<Element> =>\n firstOf(shown, 'h1') ?? firstOf(shown, 'h1, h2, h3, h4, h5, h6');\n\n// a screen reader starts reading the new page there; the scroll is the router's to set\nexport const focusOn = (target: Nullable<Element>): void => {\n if (isInstanceOf(HTMLElement, target)) {\n if (!target.hasAttribute('tabindex')) {\n target.tabIndex = -1;\n }\n target.focus({ preventScroll: true });\n }\n};\n","import type { NavigateOptions } from './router.navigate';\n\n/** What `show` returns: the element a page is shown in, or the nodes it put on screen. */\nexport type Shown = Node | readonly Node[];\n\n/**\n * Where a router's addresses come from and how it moves between them: the browser's history, or\n * `memoryHistory` for a router inside a part of the page.\n */\nexport interface RouterHistory {\n /** The address now. */\n url: () => URL;\n /** The path of the page shown, read like a signal. */\n path: () => string;\n /** Whether the next page is loading, read like a signal. */\n loading: () => boolean;\n /** The router says a page for the address now starts loading, or that loading is over. */\n loads: (loading: boolean) => void;\n /** Goes to an address, as a click on a link to it does. */\n navigate: (to: string | URL, options?: NavigateOptions) => void;\n /** Calls `moved` after each move until the stop it returns. */\n follow: (moved: () => void) => () => void;\n /** Takes the address now for the page about to be shown, so the page reads its path while it renders. */\n showing: () => void;\n /** Brings the reader to the page just shown; `moved` is false for the first page. */\n arrive: (shown: Shown, moved: boolean) => void;\n}\n\n// routes answer a path and its query; a new query is a new page as well, a new place on it is not\nexport const pageAddress = ({ pathname, search }: URL | Location | HTMLAnchorElement): string => `${pathname}${search}`;\n\n/** Any address parses against it where only its path and query matter, never its origin. */\nexport const anyOrigin = 'https://reely.invalid';\n","import { forEachSettled } from '@reely/basics';\nimport { signal } from '@reely/signals';\n\nimport { focusOn, headingIn } from './router.focus';\nimport { anyOrigin } from './router.history';\n\nimport type { RouterHistory } from './router.history';\n\n/**\n * A history kept in memory, for a router inside part of the page (a widget, a dialog, a demo): it\n * starts at `start` and leaves the document's URL alone. It follows the links `followLinks` gives it.\n */\nexport const memoryHistory = (start = '/'): RouterHistory => {\n let now = new URL(start, anyOrigin);\n const shown = signal(now.pathname);\n const loading = signal(false);\n const movers = new Set<() => void>();\n\n return {\n url: () => new URL(now),\n path: () => shown.value,\n loading: () => loading.value,\n loads: (value): void => {\n loading.value = value;\n },\n navigate: (to): void => {\n const next = new URL(to, now);\n if (next.href !== now.href) {\n now = next;\n forEachSettled(movers, (moved) => moved());\n }\n },\n follow: (moved) => {\n movers.add(moved);\n return (): void => void movers.delete(moved);\n },\n showing: (): void => {\n shown.value = now.pathname;\n },\n arrive: (page, moved): void => {\n if (moved) {\n focusOn(headingIn(page));\n }\n },\n };\n};\n","// a move made by code: the page left is told first, then the new URL; `pushState` fires no `popstate`\nexport const leavingEvent = 'reely:leaving';\nexport const navigationEvent = 'reely:navigate';\n\nexport interface NavigateOptions {\n /** Takes the place of the current history entry, as a redirect does, instead of adding one. */\n replace?: boolean;\n}\n\n/** Goes to a URL of this site without loading the document; the router shows its page. */\nexport const navigate = (to: string | URL, { replace = false }: NavigateOptions = {}): void => {\n const url = new URL(to, location.href);\n if (url.href === location.href) {\n return;\n }\n if (replace) {\n history.replaceState(null, '', url);\n } else {\n window.dispatchEvent(new Event(leavingEvent));\n history.pushState(null, '', url);\n }\n window.dispatchEvent(new Event(navigationEvent));\n};\n","// a part of a URL decoded; one that is not valid percent-encoding stays as it came\nexport const decode = (value: string): string => {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n};\n","import { hasSome } from '@reely/basics';\nimport { isNil } from '@reely/utils';\n\nimport { decode } from './router.decode';\nimport { anyOrigin } from './router.history';\n\nimport type { ParamsOf, Routes } from './router.types';\n\ntype Params = Readonly<Record<string, string>>;\n\n/**\n * A route table: each path pattern with the function that answers it from the params the pattern\n * names and the address's query. An answer is a page, a promise of one (`import()`), or nothing to\n * pass the address on to the next route.\n */\nexport type RouteTable<T> = { [P in keyof T & string]: (params: ParamsOf<P>, query: URLSearchParams) => T[P] };\n\n/** The pages a route table answers with, loaded. */\nexport type PageOf<T> = NonNullable<Awaited<T[keyof T]>>;\n\nconst segmentsOf = (path: string): string[] => path.split('/').filter((segment) => segment !== '');\n\n// the params a pattern takes from a pathname's segments, or undefined when it does not match them\nconst matchSegments = (pattern: readonly string[], segments: readonly string[]): Params | undefined => {\n const params: Record<string, string> = {};\n for (const [index, part] of pattern.entries()) {\n if (part.startsWith('*')) {\n params[part.slice(1)] = decode(segments.slice(index).join('/'));\n return params;\n }\n const segment = segments[index];\n if (isNil(segment) || (!part.startsWith(':') && part !== segment)) {\n return undefined;\n }\n if (part.startsWith(':')) {\n params[part.slice(1)] = decode(segment);\n }\n }\n return segments.length === pattern.length ? params : undefined;\n};\n\n/**\n * Turns a table of path patterns into the app's routes: an address gets the page of the first route\n * that matches its path and answers. `:name` takes one segment, `*name` the rest of the path; a route\n * that answers nothing passes the address on to the next one.\n */\nexport function defineRoutes<T>(table: RouteTable<T>): Routes<PageOf<T>>;\nexport function defineRoutes(\n table: Readonly<Record<string, (params: Params, query: URLSearchParams) => unknown>>\n): Routes<unknown> {\n const routes = Object.entries(table).map(([pattern, answer]) => ({ pattern: segmentsOf(pattern), answer }));\n return async (address) => {\n const { pathname, searchParams } = new URL(address, anyOrigin);\n const segments = segmentsOf(pathname);\n for (const { pattern, answer } of routes) {\n const params = matchSegments(pattern, segments);\n const page: unknown = hasSome(params) ? await answer(params, searchParams) : undefined;\n if (hasSome(page)) {\n return page;\n }\n }\n return undefined;\n };\n}\n","import { isPlainObject } from '@reely/basics';\nimport { hasProperty, isNumber } from '@reely/utils';\nimport type { Nullable } from '@reely/utils';\n\nimport { decode } from './router.decode';\n\nconst scrollKey = 'reelyScroll';\n\n/** Where the reader had scrolled the page of the current history entry, kept in its state for a reload too. */\nexport const keptScroll = (): number | undefined => {\n const { state } = history;\n const scrolled = hasProperty(scrollKey, state) ? state[scrollKey] : undefined;\n return isNumber(scrolled) ? scrolled : undefined;\n};\n\n/** Keeps where the page of the current history entry is scrolled, and what else its state held. */\nexport const keepScroll = (): void => {\n history.replaceState({ ...(isPlainObject(history.state) ? history.state : {}), [scrollKey]: window.scrollY }, '');\n};\n\n/** The element the hash of the location names. */\nexport const placeInUrl = (): Nullable<HTMLElement> =>\n location.hash === '' ? null : document.getElementById(decode(location.hash.slice(1)));\n","import { hasSome } from '@reely/basics';\n\nimport { currentPath, pageLoading, showLoading, showPath } from './router.current';\nimport { focusOn, headingIn } from './router.focus';\nimport { pageAddress } from './router.history';\nimport { takeOverLinks } from './router.links';\nimport { leavingEvent, navigate, navigationEvent } from './router.navigate';\nimport { keepScroll, keptScroll, placeInUrl } from './router.scroll';\n\nimport type { RouterHistory } from './router.history';\n\n/**\n * The browser's own history: links of this site anywhere in the document, back and forward, and\n * `navigate`; each page opens where the reader had scrolled it, at the place its URL names, or at\n * the top.\n */\nexport const browserHistory = (): RouterHistory => {\n let shown = '';\n\n return {\n url: () => new URL(location.href),\n path: currentPath,\n loading: pageLoading,\n loads: showLoading,\n navigate,\n follow: (moved) => {\n const listening = new AbortController();\n const { signal } = listening;\n const { scrollRestoration } = history;\n let saving: ReturnType<typeof setTimeout> | undefined;\n // the page shown keeps where it is scrolled, for a reload and for the way back to it\n const saveScroll = (): void => {\n if (pageAddress(location) === shown) {\n keepScroll();\n }\n };\n // a link to a place on the page shown is the browser's to follow\n takeOverLinks(\n document,\n (link) => {\n if (pageAddress(link) === shown && link.hash !== '') {\n return false;\n }\n navigate(link.href);\n return true;\n },\n signal\n );\n window.addEventListener('popstate', moved, { signal });\n window.addEventListener(navigationEvent, moved, { signal });\n window.addEventListener(\n 'scroll',\n () => {\n clearTimeout(saving);\n saving = setTimeout(saveScroll, 100);\n },\n { signal, passive: true }\n );\n window.addEventListener('pagehide', saveScroll, { signal });\n window.addEventListener(leavingEvent, saveScroll, { signal });\n // pages load after a move, so the browser's own restoring would scroll the page before it is there\n history.scrollRestoration = 'manual';\n return () => {\n listening.abort();\n clearTimeout(saving);\n history.scrollRestoration = scrollRestoration;\n };\n },\n showing: (): void => {\n shown = pageAddress(location);\n showPath(location.pathname);\n },\n arrive: (page, moved): void => {\n const place = placeInUrl();\n const scrolled = keptScroll();\n if (hasSome(scrolled)) {\n window.scrollTo(0, scrolled);\n } else if (hasSome(place)) {\n place.scrollIntoView();\n } else if (moved) {\n window.scrollTo(0, 0);\n }\n focusOn(place ?? (moved ? headingIn(page) : null));\n },\n };\n};\n","import { reportUncaught, toErrorWithMessage } from '@reely/basics';\nimport { batch, onCleanup } from '@reely/signals';\nimport { identity } from '@reely/utils';\nimport type { Nullable } from '@reely/utils';\n\nimport { browserHistory } from './router.browser';\nimport { pageAddress } from './router.history';\n\nimport type { RouterHistory, Shown } from './router.history';\nimport type { NavigateOptions } from './router.navigate';\nimport type { Routes } from './router.types';\n\nexport interface RouterOptions<Page> {\n /** Puts a page on screen; returns the element it is shown in, or its nodes, whose heading a move focuses. */\n show: (page: Page) => Shown;\n /** The page for what went wrong: a route that fails or fails to load, an address no route answers, a page that fails to show. */\n fail: (error: Error) => Page;\n /** Where the addresses come from: the browser's history when left out, or a `memoryHistory`. */\n history?: RouterHistory;\n}\n\nexport interface Router {\n /** The path of the page shown, read like a signal: an effect that reads it follows every move. */\n path: () => string;\n /** Whether a page is loading, read like a signal; the page shown stays until it has loaded. */\n loading: () => boolean;\n /** Goes to an address of this router, as a click on a link to it does. */\n navigate: (to: string | URL, options?: NavigateOptions) => void;\n /** Stops following moves; a router started under an owner stops with it too. */\n stop: () => void;\n}\n\n/**\n * Shows the page of the current address and keeps following it: a click on a link of this site, the\n * browser's back and forward, and `navigate` show the next page without loading the document. The\n * page shown stays until the next one has loaded; only the latest move counts.\n */\nexport const startRouter = <Page>(\n routes: Routes<Page>,\n { show, fail, history = browserHistory() }: RouterOptions<NoInfer<Page>>\n): Router => {\n let latest = 0;\n let requested = pageAddress(history.url());\n let shown = '';\n let onScreen: Shown = [];\n\n const failure = (error: unknown): Page => fail(toErrorWithMessage(error));\n\n const display = (page: Page, url: URL, moved: boolean): void => {\n shown = pageAddress(url);\n batch(() => {\n history.loads(false);\n history.showing();\n });\n try {\n onScreen = show(page);\n } catch (error) {\n onScreen = show(failure(error));\n }\n history.arrive(onScreen, moved);\n };\n\n const load = (moved: boolean): void => {\n const turn = ++latest;\n const url = history.url();\n history.loads(true);\n // a route that throws rejects like one whose page fails to load\n Promise.resolve()\n .then(() => routes(pageAddress(url)))\n .then((page) => page ?? Promise.reject(new Error(`No route answers ${url.pathname}`)))\n .then(identity, failure)\n .then((page) => {\n if (turn === latest) {\n display(page, url, moved);\n }\n })\n .catch((error: unknown) => {\n // even the failure page failed: nothing more is coming for this move\n if (turn === latest) {\n history.loads(false);\n }\n reportUncaught(error);\n });\n };\n\n const move = (): void => {\n const address = pageAddress(history.url());\n if (address === shown) {\n // back on the page shown, or a new place on it: a load still pending for another is dropped\n latest += 1;\n requested = address;\n history.loads(false);\n history.arrive(onScreen, true);\n } else if (address !== requested) {\n requested = address;\n load(true);\n }\n };\n\n let unfollow: Nullable<() => void> = history.follow(move);\n const stop = (): void => {\n unfollow?.();\n unfollow = null;\n latest += 1;\n history.loads(false);\n };\n onCleanup(stop);\n load(false);\n\n return { path: history.path, loading: history.loading, navigate: history.navigate, stop };\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reely/router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A small typed router for any web app: routes from path patterns, lazy pages, links and history taken over, scroll and focus kept, no framework.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/JsPowWow/reely.git"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!**/*.tsbuildinfo"
|
|
29
|
+
],
|
|
30
|
+
"nx": {
|
|
31
|
+
"tags": [
|
|
32
|
+
"scope:shared"
|
|
33
|
+
],
|
|
34
|
+
"projectType": "library",
|
|
35
|
+
"sourceRoot": "packages/router/src",
|
|
36
|
+
"targets": {
|
|
37
|
+
"build": {
|
|
38
|
+
"executor": "nx:run-commands",
|
|
39
|
+
"options": {
|
|
40
|
+
"command": "vite build",
|
|
41
|
+
"cwd": "packages/router"
|
|
42
|
+
},
|
|
43
|
+
"dependsOn": [
|
|
44
|
+
"^build"
|
|
45
|
+
],
|
|
46
|
+
"outputs": [
|
|
47
|
+
"{projectRoot}/dist"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"consumer": {
|
|
51
|
+
"executor": "nx:run-commands",
|
|
52
|
+
"options": {
|
|
53
|
+
"command": "node ../../scripts/consumer-check.mjs --readme",
|
|
54
|
+
"cwd": "packages/router"
|
|
55
|
+
},
|
|
56
|
+
"dependsOn": [
|
|
57
|
+
"build",
|
|
58
|
+
"^build"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@reely/utils": "*"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@reely/basics": "^0.1.1",
|
|
68
|
+
"@reely/signals": "^0.1.1"
|
|
69
|
+
}
|
|
70
|
+
}
|