aberdeen 2.0.0 → 2.0.1

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.
Files changed (55) hide show
  1. package/dist/src/route.d.ts +8 -8
  2. package/dist/src/route.js +57 -64
  3. package/dist/src/route.js.map +3 -3
  4. package/dist/src/server.d.ts +2 -2
  5. package/dist/src/server.js.map +1 -1
  6. package/dist-docs/Tutorial/index.html +2 -2
  7. package/dist-docs/aberdeen/Aberdeen/index.html +31 -31
  8. package/dist-docs/aberdeen/InitOptions/index.html +3 -3
  9. package/dist-docs/aberdeen/KeyToString/index.html +2 -2
  10. package/dist-docs/aberdeen/PromiseProxy/index.html +5 -5
  11. package/dist-docs/aberdeen/SortKeyType/index.html +2 -2
  12. package/dist-docs/aberdeen/TargetType/index.html +2 -2
  13. package/dist-docs/aberdeen/ValueRef/index.html +2 -2
  14. package/dist-docs/aberdeen/WindowLike/index.html +3 -3
  15. package/dist-docs/aberdeen/createA/index.html +2 -2
  16. package/dist-docs/aberdeen/index.html +2 -2
  17. package/dist-docs/assets/aberdeen/route.d.ts +8 -8
  18. package/dist-docs/assets/aberdeen/route.js +57 -64
  19. package/dist-docs/assets/aberdeen/route.js.map +3 -3
  20. package/dist-docs/assets/aberdeen/server.d.ts +2 -2
  21. package/dist-docs/assets/aberdeen/server.js.map +1 -1
  22. package/dist-docs/assets/navigation.js +1 -1
  23. package/dist-docs/assets/search.js +1 -1
  24. package/dist-docs/dispatcher/Dispatcher/index.html +4 -4
  25. package/dist-docs/dispatcher/MATCH_FAILED/index.html +2 -2
  26. package/dist-docs/dispatcher/MATCH_REST/index.html +2 -2
  27. package/dist-docs/dispatcher/index.html +2 -2
  28. package/dist-docs/hierarchy.html +1 -1
  29. package/dist-docs/index-1/index.html +2 -2
  30. package/dist-docs/index.html +2 -2
  31. package/dist-docs/media/CHANGELOG.md +1 -1
  32. package/dist-docs/modules.html +1 -1
  33. package/dist-docs/prediction/applyCanon/index.html +2 -2
  34. package/dist-docs/prediction/applyPrediction/index.html +2 -2
  35. package/dist-docs/prediction/index.html +2 -2
  36. package/dist-docs/route/RouteApi/index.html +14 -14
  37. package/dist-docs/route/{Route → RouteState}/index.html +9 -9
  38. package/dist-docs/route/{init → createRoute}/index.html +2 -2
  39. package/dist-docs/route/index.html +4 -4
  40. package/dist-docs/server/RenderToStringOptions/index.html +4 -4
  41. package/dist-docs/server/RenderToStringResult/index.html +4 -4
  42. package/dist-docs/server/index.html +2 -2
  43. package/dist-docs/server/renderToString/index.html +2 -2
  44. package/dist-docs/sitemap.xml +36 -36
  45. package/dist-docs/transitions/grow/index.html +2 -2
  46. package/dist-docs/transitions/index.html +2 -2
  47. package/dist-docs/transitions/shrink/index.html +2 -2
  48. package/package.json +1 -1
  49. package/skill/aberdeen.md +45 -45
  50. package/skill/dispatcher.md +6 -6
  51. package/skill/prediction.md +3 -3
  52. package/skill/route.md +90 -90
  53. package/skill/transitions.md +3 -3
  54. package/src/route.ts +295 -319
  55. package/src/server.ts +2 -2
package/src/route.ts CHANGED
@@ -3,7 +3,7 @@ type NavType = "load" | "back" | "forward" | "go" | "push";
3
3
  /**
4
4
  * The class for the global `route` object.
5
5
  */
6
- export interface Route {
6
+ export interface RouteState {
7
7
  /** The current path of the URL as a string. For instance `"/"` or `"/users/123/feed"`. Paths are normalized to always start with a `/` and never end with a `/` (unless it's the root path). */
8
8
  path: string;
9
9
  /** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', '123', 'feed']` (for `"/users/123/feed"`). */
@@ -26,7 +26,7 @@ export interface Route {
26
26
  nav: NavType;
27
27
  }
28
28
 
29
- type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<Route,"p">,"search"> & {
29
+ type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<RouteState,"p">,"search"> & {
30
30
  /** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', 123, 'feed']` (for `"/users/123/feed"`). Values may be integers but will be converted to strings.*/
31
31
  p: (string|number)[],
32
32
  /** The query string interpreted as search parameters. So `"a=x&b=y"` becomes `{a: "x", b: "y", c: 42}`. Values may be integers but will be converted to strings. */
@@ -34,13 +34,13 @@ type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<Route,"p">,"se
34
34
  }>;
35
35
 
36
36
  /**
37
- * The router API returned by {@link init}, bound to a specific Aberdeen instance's window.
37
+ * The router API returned by {@link createRoute}, bound to a specific Aberdeen instance's window.
38
38
  */
39
39
  export interface RouteApi {
40
40
  /**
41
- * The global {@link Route} object reflecting the current URL and browser history state. Changes you make to this affect the current browser history item (modifying the URL if needed).
41
+ * The global {@link RouteState} object reflecting the current URL and browser history state. Changes you make to this affect the current browser history item (modifying the URL if needed).
42
42
  */
43
- current: Route;
43
+ current: RouteState;
44
44
  /**
45
45
  * Configure logging on route changes.
46
46
  * @param value `true` to enable logging to console, `false` to disable logging, or a custom logging function. Defaults to `false`.
@@ -51,7 +51,7 @@ export interface RouteApi {
51
51
  *
52
52
  * Note that this happens synchronously, immediately updating `route` and processing any reactive updates based on that.
53
53
  *
54
- * @param target A subset of the {@link Route} properties to navigate to. If neither `p` nor `path` is given, the current path is used. For other properties, an empty/default value is assumed if not given. For convenience:
54
+ * @param target A subset of the {@link RouteState} properties to navigate to. If neither `p` nor `path` is given, the current path is used. For other properties, an empty/default value is assumed if not given. For convenience:
55
55
  * - You may pass a string instead of an object, which is interpreted as the `path`.
56
56
  * - You may pass an array instead of an object, which is interpreted as the `p` array.
57
57
  * - If you pass `p`, it may contain numbers, which will be converted to strings.
@@ -103,7 +103,7 @@ export interface RouteApi {
103
103
  *
104
104
  * Consider using {@link up} to go up in the path hierarchy.
105
105
  *
106
- * @param target The target route to go back to. May be a subset of {@link Route}, or a string (for `path`), or an array of strings (for `p`).
106
+ * @param target The target route to go back to. May be a subset of {@link RouteState}, or a string (for `path`), or an array of strings (for `p`).
107
107
  */
108
108
  back(target?: RouteTarget): void;
109
109
  /**
@@ -116,17 +116,6 @@ export interface RouteApi {
116
116
  up(stripCount?: number): void;
117
117
  /** @internal */
118
118
  reset(): void;
119
- /**
120
- * Run the router's browser-dependent setup: sync the `route` proxy to the current URL,
121
- * start listening for `popstate`, and start the observers that keep the browser URL in sync
122
- * with `route`. Safe to call multiple times (subsequent calls are no-ops).
123
- *
124
- * This runs automatically on import in a browser. Outside a browser it is a no-op until a
125
- * window has been provided via `setWindow`, after which you should call it yourself (or call
126
- * `reset` to re-sync to a new request URL). See the `aberdeen/server` SSR entry point.
127
- * @internal
128
- */
129
- initRouter(): void;
130
119
  /**
131
120
  * Restore and store the vertical and horizontal scroll position for
132
121
  * the parent element to the page state.
@@ -163,361 +152,348 @@ export interface RouteApi {
163
152
  *
164
153
  * @param A An Aberdeen instance (the default export of `aberdeen`, or one from `init`).
165
154
  */
166
- export function init(A: any): RouteApi {
155
+ export function createRoute(A: any): RouteApi {
167
156
 
168
- let log: (...args: any) => void = () => {};
157
+ const window = A.window;
158
+ const location = window.location;
159
+ const history = window.history;
169
160
 
170
- function setLog(value: boolean | ((...args: any[]) => void)) {
171
- if (value === true) {
172
- log = console.log.bind(console, 'aberdeen router');
173
- } else if (value === false) {
174
- log = () => {};
175
- } else {
176
- log = value;
161
+ let log: (...args: any) => void = () => {};
162
+
163
+ function setLog(value: boolean | ((...args: any[]) => void)) {
164
+ if (value === true) {
165
+ log = console.log.bind(console, 'aberdeen router');
166
+ } else if (value === false) {
167
+ log = () => {};
168
+ } else {
169
+ log = value;
170
+ }
177
171
  }
178
- }
179
172
 
180
- // The window/location/history are resolved lazily (rather than captured at import time) so
181
- // that this module can be imported in a non-browser environment and have a window injected
182
- // later via `setWindow` (see the `aberdeen/server` SSR entry point).
183
- const windowE = () => A.window;
184
- const locationE = () => A.window.location;
185
- const historyE = () => A.window.history;
186
-
187
- function getRouteFromBrowser(): Route {
188
- return toCanonRoute({
189
- path: locationE().pathname,
190
- hash: locationE().hash,
191
- search: Object.fromEntries(new URLSearchParams(locationE().search)),
192
- state: historyE().state?.state || {},
193
- }, "load", (historyE().state?.stack?.length || 0) + 1);
194
- }
173
+ function getRouteFromBrowser(): RouteState {
174
+ return toCanonRoute({
175
+ path: location.pathname,
176
+ hash: location.hash,
177
+ search: Object.fromEntries(new URLSearchParams(location.search)),
178
+ state: history.state?.state || {},
179
+ }, "load", (history.state?.stack?.length || 0) + 1);
180
+ }
195
181
 
196
- /**
197
- * Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
198
- * of their counterparts in `a` and still be considered equal.
199
- */
200
- function equal(a: any, b: any, partial: boolean): boolean {
201
- if (a===b) return true;
202
- if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false; // otherwise they would have been equal
203
- if (a.constructor !== b.constructor) return false;
204
- if (b instanceof Array) {
205
- if (a.length !== b.length) return false;
206
- for(let i = 0; i < b.length; i++) {
207
- if (!equal(a[i], b[i], partial)) return false;
208
- }
209
- } else {
210
- for(const k of Object.keys(b)) {
211
- if (!equal(a[k], b[k], partial)) return false;
212
- }
213
- if (!partial) {
214
- for(const k of Object.keys(a)) {
215
- if (!b.hasOwnProperty(k)) return false;
182
+ /**
183
+ * Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
184
+ * of their counterparts in `a` and still be considered equal.
185
+ */
186
+ function equal(a: any, b: any, partial: boolean): boolean {
187
+ if (a===b) return true;
188
+ if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false; // otherwise they would have been equal
189
+ if (a.constructor !== b.constructor) return false;
190
+ if (b instanceof Array) {
191
+ if (a.length !== b.length) return false;
192
+ for(let i = 0; i < b.length; i++) {
193
+ if (!equal(a[i], b[i], partial)) return false;
194
+ }
195
+ } else {
196
+ for(const k of Object.keys(b)) {
197
+ if (!equal(a[k], b[k], partial)) return false;
198
+ }
199
+ if (!partial) {
200
+ for(const k of Object.keys(a)) {
201
+ if (!b.hasOwnProperty(k)) return false;
202
+ }
216
203
  }
217
204
  }
205
+ return true;
218
206
  }
219
- return true;
220
- }
221
207
 
222
- function getUrl(target: Route) {
223
- const search = new URLSearchParams(target.search).toString();
224
- return (search ? `${target.path}?${search}` : target.path) + target.hash;
225
- }
208
+ function getUrl(target: RouteState) {
209
+ const search = new URLSearchParams(target.search).toString();
210
+ return (search ? `${target.path}?${search}` : target.path) + target.hash;
211
+ }
226
212
 
227
- function toCanonRoute(target: Partial<Route>, nav: NavType, depth: number): Route {
228
- let path = target.path || (target.p || []).join("/") || "/";
229
- path = (""+path).replace(/\/+$/, "");
230
- if (!path.startsWith("/")) path = `/${path}`;
231
-
232
- return {
233
- path,
234
- hash: target.hash && target.hash !=="#" ? (target.hash.startsWith("#") ? target.hash : "#" + target.hash) : "",
235
- p: path.length > 1 ? path.slice(1).replace(/\/+$/, "").split("/") : [],
236
- nav,
237
- search: typeof target.search === 'object' && target.search ? A.clone(target.search) : {},
238
- state: typeof target.state === 'object' && target.state ? A.clone(target.state) : {},
239
- depth,
240
- };
241
- }
213
+ function toCanonRoute(target: Partial<RouteState>, nav: NavType, depth: number): RouteState {
214
+ let path = target.path || (target.p || []).join("/") || "/";
215
+ path = (""+path).replace(/\/+$/, "");
216
+ if (!path.startsWith("/")) path = `/${path}`;
217
+
218
+ return {
219
+ path,
220
+ hash: target.hash && target.hash !=="#" ? (target.hash.startsWith("#") ? target.hash : "#" + target.hash) : "",
221
+ p: path.length > 1 ? path.slice(1).replace(/\/+$/, "").split("/") : [],
222
+ nav,
223
+ search: typeof target.search === 'object' && target.search ? A.clone(target.search) : {},
224
+ state: typeof target.state === 'object' && target.state ? A.clone(target.state) : {},
225
+ depth,
226
+ };
227
+ }
242
228
 
243
229
 
244
- function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<Route> | undefined;
245
- function targetToPartial(target: RouteTarget): Partial<Route>;
230
+ function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<RouteState> | undefined;
231
+ function targetToPartial(target: RouteTarget): Partial<RouteState>;
246
232
 
247
233
 
248
- function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
249
- // Convert shortcut values to objects
250
- if (typeof target === 'string') {
251
- // Parse using URL to handle both absolute and relative paths correctly
252
- const url = new URL(target, locationE().href);
253
- if (url.host !== locationE().host) {
254
- if (undefinedOnExternal) return;
255
- throw new Error(`Unexpected external URL: ${url.host} != ${locationE().host}`);
234
+ function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
235
+ // Convert shortcut values to objects
236
+ if (typeof target === 'string') {
237
+ // Parse using URL to handle both absolute and relative paths correctly
238
+ const url = new URL(target, location.href);
239
+ if (url.host !== location.host) {
240
+ if (undefinedOnExternal) return;
241
+ throw new Error(`Unexpected external URL: ${url.host} != ${location.host}`);
242
+ }
243
+ target = {
244
+ path: url.pathname,
245
+ search: Object.fromEntries(url.searchParams),
246
+ hash: url.hash,
247
+ };
248
+ } else if (target instanceof Array) {
249
+ target = {p: target};
256
250
  }
257
- target = {
258
- path: url.pathname,
259
- search: Object.fromEntries(url.searchParams),
260
- hash: url.hash,
261
- };
262
- } else if (target instanceof Array) {
263
- target = {p: target};
264
- }
265
- // Convert numbers in p and search to strings
266
- if (target.p) {
267
- target.p = target.p.map(String);
268
- }
269
- if (target.search) {
270
- for(const key of Object.keys(target.search)) {
271
- target.search[key] = String(target.search[key]);
251
+ // Convert numbers in p and search to strings
252
+ if (target.p) {
253
+ target.p = target.p.map(String);
254
+ }
255
+ if (target.search) {
256
+ for(const key of Object.keys(target.search)) {
257
+ target.search[key] = String(target.search[key]);
258
+ }
272
259
  }
260
+ return target as Partial<RouteState>;
273
261
  }
274
- return target as Partial<Route>;
275
- }
276
262
 
277
263
 
278
- function go(target: RouteTarget, nav: NavType = "go"): void {
279
- pendingGoOffset = 0;
280
- const stack: string[] = historyE().state?.stack || [];
264
+ function go(target: RouteTarget, nav: NavType = "go"): void {
265
+ pendingGoOffset = 0;
266
+ const stack: string[] = history.state?.stack || [];
281
267
 
282
- prevStack = stack.concat(JSON.stringify(A.unproxy(current)));
283
-
284
- const newRoute: Route = toCanonRoute(targetToPartial(target), nav, prevStack.length + 1);
285
- A.copy(current, newRoute);
286
-
287
- log(nav, newRoute);
288
- historyE().pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
289
-
290
- A.runQueue();
291
- }
268
+ prevStack = stack.concat(JSON.stringify(A.unproxy(current)));
269
+
270
+ const newRoute: RouteState = toCanonRoute(targetToPartial(target), nav, prevStack.length + 1);
271
+ A.copy(current, newRoute);
272
+
273
+ log(nav, newRoute);
274
+ history.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
275
+
276
+ A.runQueue();
277
+ }
292
278
 
293
- function matchCurrent(target: RouteTarget): boolean {
294
- const partial = targetToPartial(target, true);
295
- if (!partial) return false; // External link
279
+ function matchCurrent(target: RouteTarget): boolean {
280
+ const partial = targetToPartial(target, true);
281
+ if (!partial) return false; // External link
296
282
 
297
- if (partial.path != null || partial.p != null) {
298
- let path = partial.path || (partial.p || []).join("/") || "/";
299
- path = (""+path).replace(/\/+$/, "");
300
- if (!path.startsWith("/")) path = `/${path}`;
301
- if (!currentRouteParts[path]) return false;
302
- }
283
+ if (partial.path != null || partial.p != null) {
284
+ let path = partial.path || (partial.p || []).join("/") || "/";
285
+ path = (""+path).replace(/\/+$/, "");
286
+ if (!path.startsWith("/")) path = `/${path}`;
287
+ if (!currentRouteParts[path]) return false;
288
+ }
303
289
 
304
- if (partial.search) {
305
- for(const [k,v] of Object.entries(partial.search)) {
306
- if (!currentRouteParts[`${k}=${v}`]) return false;
290
+ if (partial.search) {
291
+ for(const [k,v] of Object.entries(partial.search)) {
292
+ if (!currentRouteParts[`${k}=${v}`]) return false;
293
+ }
307
294
  }
295
+ return true;
308
296
  }
309
- return true;
310
- }
311
297
 
312
- function push(target: RouteTarget, nav?: NavType): void {
313
- const c = A.clone(A.unproxy(current));
314
- A.merge(c, targetToPartial(target));
315
- go(c, nav || c.nav);
316
- }
298
+ function push(target: RouteTarget, nav?: NavType): void {
299
+ const c = A.clone(A.unproxy(current));
300
+ A.merge(c, targetToPartial(target));
301
+ go(c, nav || c.nav);
302
+ }
317
303
 
318
- function back(target: RouteTarget = {}): void {
319
- const partial = targetToPartial(target);
320
- const stack: string[] = historyE().state?.stack || [];
321
- const effectiveLen = stack.length + pendingGoOffset;
322
- for(let i = effectiveLen - 1; i >= 0; i--) {
323
- const histRoute: Route = JSON.parse(stack[i]);
324
- if (equal(histRoute, partial, true)) {
325
- const pages = i - effectiveLen;
326
- log(`back`, pages, histRoute);
327
- scheduleHistoryGo(pages);
328
- return;
304
+ function back(target: RouteTarget = {}): void {
305
+ const partial = targetToPartial(target);
306
+ const stack: string[] = history.state?.stack || [];
307
+ const effectiveLen = stack.length + pendingGoOffset;
308
+ for(let i = effectiveLen - 1; i >= 0; i--) {
309
+ const histRoute: RouteState = JSON.parse(stack[i]);
310
+ if (equal(histRoute, partial, true)) {
311
+ const pages = i - effectiveLen;
312
+ log(`back`, pages, histRoute);
313
+ scheduleHistoryGo(pages);
314
+ return;
315
+ }
329
316
  }
330
- }
331
317
 
332
- const newRoute = toCanonRoute(partial, "back", effectiveLen + 1);
333
- log(`back not found, replacing`, partial);
334
- A.copy(current, newRoute);
335
- }
318
+ const newRoute = toCanonRoute(partial, "back", effectiveLen + 1);
319
+ log(`back not found, replacing`, partial);
320
+ A.copy(current, newRoute);
321
+ }
336
322
 
337
- function up(stripCount: number = 1): void {
338
- const currentP = A.unproxy(current).p;
339
- const stack: string[] = historyE().state?.stack || [];
340
- const effectiveLen = stack.length + pendingGoOffset;
341
- for(let i = effectiveLen - 1; i >= 0; i--) {
342
- const histRoute: Route = JSON.parse(stack[i]);
343
- if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
344
- // This route is shorter and matches the start of the current path
345
- log(`up to ${i+1} / ${effectiveLen}`, histRoute);
346
- scheduleHistoryGo(i - effectiveLen);
347
- return;
323
+ function up(stripCount: number = 1): void {
324
+ const currentP = A.unproxy(current).p;
325
+ const stack: string[] = history.state?.stack || [];
326
+ const effectiveLen = stack.length + pendingGoOffset;
327
+ for(let i = effectiveLen - 1; i >= 0; i--) {
328
+ const histRoute: RouteState = JSON.parse(stack[i]);
329
+ if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
330
+ // This route is shorter and matches the start of the current path
331
+ log(`up to ${i+1} / ${effectiveLen}`, histRoute);
332
+ scheduleHistoryGo(i - effectiveLen);
333
+ return;
334
+ }
348
335
  }
336
+ // Replace current route with /
337
+ const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
338
+ log(`up not found, replacing`, newRoute);
339
+ A.copy(current, newRoute);
349
340
  }
350
- // Replace current route with /
351
- const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
352
- log(`up not found, replacing`, newRoute);
353
- A.copy(current, newRoute);
354
- }
355
341
 
356
342
 
357
- let prevStack: string[];
343
+ let prevStack: string[];
358
344
 
359
- // Track pending historyE().go() offset. Multiple back()/up() calls before the event loop
360
- // processes them are batched into a single historyE().go() via queueMicrotask.
361
- let pendingGoOffset = 0;
345
+ // Track pending historyE().go() offset. Multiple back()/up() calls before the event loop
346
+ // processes them are batched into a single historyE().go() via queueMicrotask.
347
+ let pendingGoOffset = 0;
362
348
 
363
- function scheduleHistoryGo(delta: number) {
364
- pendingGoOffset += delta;
365
- (A.window.setTimeout ?? setTimeout)(() => {
366
- if (pendingGoOffset) {
367
- const offset = pendingGoOffset;
368
- pendingGoOffset = 0;
369
- historyE().go(offset);
370
- }
371
- }, 0);
372
- }
373
-
374
- const current: Route = A.proxy({}) as Route;
349
+ function scheduleHistoryGo(delta: number) {
350
+ pendingGoOffset += delta;
351
+ (A.window.setTimeout ?? setTimeout)(() => {
352
+ if (pendingGoOffset) {
353
+ const offset = pendingGoOffset;
354
+ pendingGoOffset = 0;
355
+ history.go(offset);
356
+ }
357
+ }, 0);
358
+ }
375
359
 
376
- // Proxied object with keys for the current path and "k=v" search pairs.
377
- // Used by matchCurrent to subscribe only to relevant route changes.
378
- const currentRouteParts = A.proxy({} as Record<string,true>);
360
+ const current: RouteState = A.proxy({}) as RouteState;
379
361
 
380
- /**
381
- * Reset the router to its initial state, based on the current browser state. Intended for testing purposes only.
382
- * @internal
383
- * */
384
- function reset() {
385
- prevStack = historyE().state?.stack || [];
386
- const initRoute = getRouteFromBrowser();
387
- log('initial', initRoute);
388
- A.copy(A.unproxy(current), initRoute);
389
- }
390
- let routerInited = false;
362
+ // Proxied object with keys for the current path and "k=v" search pairs.
363
+ // Used by matchCurrent to subscribe only to relevant route changes.
364
+ const currentRouteParts = A.proxy({} as Record<string,true>);
391
365
 
392
- function initRouter() {
393
- if (routerInited || !A.window) return;
394
- routerInited = true;
366
+ /**
367
+ * Reset the router to its initial state, based on the current browser state. Intended for testing purposes only.
368
+ * @internal
369
+ * */
370
+ function reset() {
371
+ prevStack = history.state?.stack || [];
372
+ const initRoute = getRouteFromBrowser();
373
+ log('initial', initRoute);
374
+ A.copy(A.unproxy(current), initRoute);
375
+ }
395
376
 
396
377
  reset();
397
378
 
398
379
  // Handle browser history back and forward
399
- windowE().addEventListener("popstate", function(event: PopStateEvent) {
400
- pendingGoOffset = 0;
401
- const newRoute = getRouteFromBrowser();
402
-
403
- // If the stack length changes, and at least the top-most shared entry is the same,
404
- // we'll interpret this as a "back" or "forward" navigation.
405
- const stack: string[] = historyE().state?.stack || [];
406
- if (stack.length !== prevStack.length) {
407
- const maxIndex = Math.min(prevStack.length, stack.length) - 1;
408
- if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
409
- newRoute.nav = stack.length < prevStack.length ? "back" : "forward";
380
+ window.addEventListener("popstate", function(event: PopStateEvent) {
381
+ pendingGoOffset = 0;
382
+ const newRoute = getRouteFromBrowser();
383
+
384
+ // If the stack length changes, and at least the top-most shared entry is the same,
385
+ // we'll interpret this as a "back" or "forward" navigation.
386
+ const stack: string[] = history.state?.stack || [];
387
+ if (stack.length !== prevStack.length) {
388
+ const maxIndex = Math.min(prevStack.length, stack.length) - 1;
389
+ if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
390
+ newRoute.nav = stack.length < prevStack.length ? "back" : "forward";
391
+ }
410
392
  }
411
- }
412
- // else nav will be "load"
413
-
414
- prevStack = stack;
415
- log('popstate', newRoute);
416
- A.copy(current, newRoute);
417
-
418
- A.runQueue();
419
- });
420
-
421
- // Make sure these observers are never cleaned up, not even by `unmountAll`.
422
- A.leakScope(() => {
423
- // Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
424
- // so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
425
- A(() => {
426
- current.path = "/" + Array.from(current.p).join("/");
427
- });
428
-
429
- // Do a replaceState based on changes to A.proxy
430
- A(() => {
431
- // First normalize `route`
432
- const stack = historyE().state?.stack || [];
433
- const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
393
+ // else nav will be "load"
394
+
395
+ prevStack = stack;
396
+ log('popstate', newRoute);
434
397
  A.copy(current, newRoute);
435
398
 
436
- // Then replace the current browser state if something actually changed
437
- const state = {state: newRoute.state, stack};
438
- const url = getUrl(newRoute);
439
- if (url !== locationE().pathname + locationE().search + locationE().hash || !equal(historyE().state, state, false)) {
440
- log('replaceState', newRoute, state, url);
441
- historyE().replaceState(state, "", url);
442
- }
399
+ A.runQueue();
443
400
  });
444
401
 
445
- // Keep currentRouteParts in sync with the current path and search params.
446
- A(() => {
447
- const n = {} as Record<string,true>;
448
- n[current.path] = true;
449
- for(const [k,v] of Object.entries(current.search)) {
450
- n[`${k}=${v}`] = true;
451
- }
452
- A.copy(currentRouteParts, n);
402
+ // Make sure these observers are never cleaned up, not even by `unmountAll`.
403
+ A.leakScope(() => {
404
+ // Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
405
+ // so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
406
+ A(() => {
407
+ current.path = "/" + Array.from(current.p).join("/");
408
+ });
409
+
410
+ // Do a replaceState based on changes to A.proxy
411
+ A(() => {
412
+ // First normalize `route`
413
+ const stack = history.state?.stack || [];
414
+ const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
415
+ A.copy(current, newRoute);
416
+
417
+ // Then replace the current browser state if something actually changed
418
+ const state = {state: newRoute.state, stack};
419
+ const url = getUrl(newRoute);
420
+ if (url !== location.pathname + location.search + location.hash || !equal(history.state, state, false)) {
421
+ log('replaceState', newRoute, state, url);
422
+ history.replaceState(state, "", url);
423
+ }
424
+ });
425
+
426
+ // Keep currentRouteParts in sync with the current path and search params.
427
+ A(() => {
428
+ const n = {} as Record<string,true>;
429
+ n[current.path] = true;
430
+ for(const [k,v] of Object.entries(current.search)) {
431
+ n[`${k}=${v}`] = true;
432
+ }
433
+ A.copy(currentRouteParts, n);
434
+ });
453
435
  });
454
- });
455
- }
456
-
457
- // Initialise immediately in a browser; a no-op (until `setWindow`) on the server.
458
- initRouter();
459
436
 
460
-
461
- function persistScroll(name = "main") {
462
- const el = A()!;
463
- A('scroll=', onScroll);
464
-
465
- const restore = A.unproxy(current).state.scroll?.[name];
466
- if (restore) {
467
- log("restoring scroll", name, restore);
468
- Object.assign(el, restore);
469
- }
470
-
471
- function onScroll() {
472
- (current.state.scroll ||= {})[name] = {
473
- scrollTop: el.scrollTop,
474
- scrollLeft: el.scrollLeft,
475
- };
476
- }
477
- }
478
-
479
- function interceptLinks() {
480
- A({
481
- click: handleEvent,
482
- keydown: handleKeyEvent,
483
- });
484
-
485
- function handleKeyEvent(e: KeyboardEvent) {
486
- if (e.key === "Enter") {
487
- handleEvent(e);
488
- }
489
- }
490
-
491
- function handleEvent(e: Event) {
492
- // Find the closest <a> tag
493
- let target = e.target as HTMLElement | null;
494
- while (target && target.tagName?.toUpperCase() !== "A") {
495
- target = target.parentElement;
496
- }
497
-
498
- if (!target) return;
437
+ function persistScroll(name = "main") {
438
+ const el = A()!;
439
+ A('scroll=', onScroll);
499
440
 
500
- const anchor = target as HTMLAnchorElement;
501
- const href = anchor.getAttribute("href");
502
-
503
- if (!href) return;
504
-
505
- // Skip hash-only links
506
- if (href.startsWith("#")) return;
507
-
508
- // Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
509
- if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
441
+ const restore = A.unproxy(current).state.scroll?.[name];
442
+ if (restore) {
443
+ log("restoring scroll", name, restore);
444
+ Object.assign(el, restore);
445
+ }
510
446
 
511
- // Skip if the link has target or download attribute
512
- if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
447
+ function onScroll() {
448
+ (current.state.scroll ||= {})[name] = {
449
+ scrollTop: el.scrollTop,
450
+ scrollLeft: el.scrollLeft,
451
+ };
452
+ }
453
+ }
454
+
455
+ function interceptLinks() {
456
+ A({
457
+ click: handleEvent,
458
+ keydown: handleKeyEvent,
459
+ });
513
460
 
514
- // Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
515
- if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
461
+ function handleKeyEvent(e: KeyboardEvent) {
462
+ if (e.key === "Enter") {
463
+ handleEvent(e);
464
+ }
465
+ }
516
466
 
517
- e.preventDefault();
518
- go(href);
467
+ function handleEvent(e: Event) {
468
+ // Find the closest <a> tag
469
+ let target = e.target as HTMLElement | null;
470
+ while (target && target.tagName?.toUpperCase() !== "A") {
471
+ target = target.parentElement;
472
+ }
473
+
474
+ if (!target) return;
475
+
476
+ const anchor = target as HTMLAnchorElement;
477
+ const href = anchor.getAttribute("href");
478
+
479
+ if (!href) return;
480
+
481
+ // Skip hash-only links
482
+ if (href.startsWith("#")) return;
483
+
484
+ // Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
485
+ if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
486
+
487
+ // Skip if the link has target or download attribute
488
+ if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
489
+
490
+ // Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
491
+ if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
492
+
493
+ e.preventDefault();
494
+ go(href);
495
+ }
519
496
  }
520
- }
521
497
 
522
- return { current, setLog, go, matchCurrent, push, back, up, reset, initRouter, persistScroll, interceptLinks } as unknown as RouteApi;
498
+ return { current, setLog, go, matchCurrent, push, back, up, reset, persistScroll, interceptLinks } as RouteApi;
523
499
  }