aberdeen 1.6.0 → 1.7.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.
package/src/dispatcher.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * Symbol to return when a custom {@link Dispatcher.addRoute} matcher cannot match a segment.
3
3
  */
4
- export const matchFailed: unique symbol = Symbol("matchFailed");
4
+ export const MATCH_FAILED: unique symbol = Symbol("MATCH_FAILED");
5
5
 
6
6
  /**
7
7
  * Special {@link Dispatcher.addRoute} matcher that matches the rest of the segments as an array of strings.
8
8
  */
9
- export const matchRest: unique symbol = Symbol("matchRest");
9
+ export const MATCH_REST: unique symbol = Symbol("MATCH_REST");
10
10
 
11
- type Matcher = string | ((segment: string) => any) | typeof matchRest;
11
+ type Matcher = string | ((segment: string) => any) | typeof MATCH_REST;
12
12
 
13
13
  type ExtractParamType<M> = M extends string
14
14
  ? never : (
15
15
  M extends ((segment: string) => infer R)
16
- ? Exclude<R, typeof matchFailed>
17
- : (M extends typeof matchRest ? string[] : never)
16
+ ? Exclude<R, typeof MATCH_FAILED>
17
+ : (M extends typeof MATCH_REST ? string[] : never)
18
18
  );
19
19
 
20
20
  type ParamsFromMatchers<T extends Matcher[]> = T extends [infer M1, ...infer Rest]
@@ -38,6 +38,9 @@ interface DispatcherRoute {
38
38
  * Example usage:
39
39
  *
40
40
  * ```ts
41
+ * import * as route from 'aberdeen/route';
42
+ * import { Dispatcher, MATCH_REST } from 'aberdeen/dispatcher';
43
+ *
41
44
  * const dispatcher = new Dispatcher();
42
45
  *
43
46
  * dispatcher.addRoute("user", Number, "stream", String, (id, stream) => {
@@ -47,7 +50,7 @@ interface DispatcherRoute {
47
50
  * dispatcher.dispatch(["user", "42", "stream", "music"]);
48
51
  * // Logs: User 42, stream music
49
52
  *
50
- * dispatcher.addRoute("search", matchRest, (terms: string[]) => {
53
+ * dispatcher.addRoute("search", MATCH_REST, (terms: string[]) => {
51
54
  * console.log("Search terms:", terms);
52
55
  * });
53
56
  *
@@ -62,8 +65,8 @@ export class Dispatcher {
62
65
  * Add a route with matchers and a handler function.
63
66
  * @param args An array of matchers followed by a handler function. Each matcher can be:
64
67
  * - A string: matches exactly that string.
65
- * - A function: takes a string segment and returns a value (of any type) if it matches, or {@link matchFailed} if it doesn't match. The return value (if not `matchFailed` and not `NaN`) is passed as a parameter to the handler function. The built-in functions `Number` and `String` can be used to match numeric and string segments respectively.
66
- * - The special {@link matchRest} symbol: matches the rest of the segments as an array of strings. Only one `matchRest` is allowed, and it must be the last matcher.
68
+ * - A function: takes a string segment and returns a value (of any type) if it matches, or {@link MATCH_FAILED} if it doesn't match. The return value (if not `MATCH_FAILED` and not `NaN`) is passed as a parameter to the handler function. The standard JavaScript functions `Number` and `String` can be used to match numeric and string segments respectively.
69
+ * - The special {@link MATCH_REST} symbol: matches the rest of the segments as an array of strings. Only one `MATCH_REST` is allowed.
67
70
  * @template T - Array of matcher types.
68
71
  * @template H - Handler function type, inferred from the matchers.
69
72
  */
@@ -75,9 +78,9 @@ export class Dispatcher {
75
78
  throw new Error("Last argument should be a handler function");
76
79
  }
77
80
 
78
- const restCount = matchers.filter(m => m === matchRest).length;
81
+ const restCount = matchers.filter(m => m === MATCH_REST).length;
79
82
  if (restCount > 1) {
80
- throw new Error("Only one matchRest is allowed");
83
+ throw new Error("Only one MATCH_REST is allowed");
81
84
  }
82
85
 
83
86
  this.routes.push({ matchers, handler });
@@ -105,7 +108,7 @@ function matchRoute(route: DispatcherRoute, segments: string[]): any[] | undefin
105
108
  let segmentIndex = 0;
106
109
 
107
110
  for (const matcher of route.matchers) {
108
- if (matcher === matchRest) {
111
+ if (matcher === MATCH_REST) {
109
112
  const len = segments.length - (route.matchers.length - 1);
110
113
  if (len < 0) return;
111
114
  args.push(segments.slice(segmentIndex, segmentIndex + len));
@@ -120,11 +123,11 @@ function matchRoute(route: DispatcherRoute, segments: string[]): any[] | undefin
120
123
  if (segment !== matcher) return;
121
124
  } else if (typeof matcher === "function") {
122
125
  const result = matcher(segment);
123
- if (result === matchFailed || (typeof result === 'number' && isNaN(result))) return;
126
+ if (result === MATCH_FAILED || (typeof result === 'number' && isNaN(result))) return;
124
127
  args.push(result);
125
128
  }
126
129
 
127
130
  segmentIndex++;
128
131
  }
129
- return args; // success!
132
+ if (segmentIndex === segments.length) return args; // success!
130
133
  }
package/src/route.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {clean, getParentElement, $, proxy, runQueue, unproxy, copy, merge, clone, leakScope} from "./aberdeen.js";
1
+ import {clean, $, proxy, runQueue, unproxy, copy, merge, clone, leakScope} from "./aberdeen.js";
2
2
 
3
3
  type NavType = "load" | "back" | "forward" | "go" | "push";
4
4
 
@@ -44,13 +44,18 @@ export function setLog(value: boolean | ((...args: any[]) => void)) {
44
44
  }
45
45
  }
46
46
 
47
+ declare const ABERDEEN_FAKE_WINDOW: Window | undefined;
48
+ const windowE = typeof ABERDEEN_FAKE_WINDOW !== 'undefined'? ABERDEEN_FAKE_WINDOW : window;
49
+ const locationE = windowE.location;
50
+ const historyE = windowE.history;
51
+
47
52
  function getRouteFromBrowser(): Route {
48
53
  return toCanonRoute({
49
- path: location.pathname,
50
- hash: location.hash,
51
- search: Object.fromEntries(new URLSearchParams(location.search)),
52
- state: history.state?.state || {},
53
- }, "load", (history.state?.stack?.length || 0) + 1);
54
+ path: locationE.pathname,
55
+ hash: locationE.hash,
56
+ search: Object.fromEntries(new URLSearchParams(locationE.search)),
57
+ state: historyE.state?.state || {},
58
+ }, "load", (historyE.state?.stack?.length || 0) + 1);
54
59
  }
55
60
 
56
61
  /**
@@ -149,7 +154,7 @@ function targetToPartial(target: RouteTarget) {
149
154
  * ```
150
155
  */
151
156
  export function go(target: RouteTarget, nav: NavType = "go"): void {
152
- const stack: string[] = history.state?.stack || [];
157
+ const stack: string[] = historyE.state?.stack || [];
153
158
 
154
159
  prevStack = stack.concat(JSON.stringify(unproxy(current)));
155
160
 
@@ -157,7 +162,7 @@ export function go(target: RouteTarget, nav: NavType = "go"): void {
157
162
  copy(current, newRoute);
158
163
 
159
164
  log(nav, newRoute);
160
- history.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
165
+ historyE.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
161
166
 
162
167
  runQueue();
163
168
  }
@@ -184,13 +189,13 @@ export function push(target: RouteTarget): void {
184
189
  */
185
190
  export function back(target: RouteTarget = {}): void {
186
191
  const partial = targetToPartial(target);
187
- const stack: string[] = history.state?.stack || [];
192
+ const stack: string[] = historyE.state?.stack || [];
188
193
  for(let i = stack.length - 1; i >= 0; i--) {
189
194
  const histRoute: Route = JSON.parse(stack[i]);
190
195
  if (equal(histRoute, partial, true)) {
191
196
  const pages = i - stack.length;
192
197
  log(`back`, pages, histRoute);
193
- history.go(pages);
198
+ historyE.go(pages);
194
199
  return;
195
200
  }
196
201
  }
@@ -209,13 +214,13 @@ export function back(target: RouteTarget = {}): void {
209
214
  */
210
215
  export function up(stripCount: number = 1): void {
211
216
  const currentP = unproxy(current).p;
212
- const stack: string[] = history.state?.stack || [];
217
+ const stack: string[] = historyE.state?.stack || [];
213
218
  for(let i = stack.length - 1; i >= 0; i--) {
214
219
  const histRoute: Route = JSON.parse(stack[i]);
215
220
  if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
216
221
  // This route is shorter and matches the start of the current path
217
222
  log(`up to ${i+1} / ${stack.length}`, histRoute);
218
- history.go(i - stack.length);
223
+ historyE.go(i - stack.length);
219
224
  return;
220
225
  }
221
226
  }
@@ -235,7 +240,7 @@ export function up(stripCount: number = 1): void {
235
240
  * The scroll position will be persisted in `route.aux.scroll.<name>`.
236
241
  */
237
242
  export function persistScroll(name = "main") {
238
- const el = getParentElement();
243
+ const el = $()!;
239
244
  el.addEventListener("scroll", onScroll);
240
245
  clean(() => el.removeEventListener("scroll", onScroll));
241
246
 
@@ -253,6 +258,72 @@ export function persistScroll(name = "main") {
253
258
  }
254
259
  }
255
260
 
261
+ /**
262
+ * Intercept clicks and Enter key presses on links (`<a>` tags) and use Aberdeen routing
263
+ * instead of browser navigation for local paths (paths without a protocol or host).
264
+ *
265
+ * This allows you to use regular HTML anchor tags for navigation without needing to
266
+ * manually attach click handlers to each link.
267
+ *
268
+ * @example
269
+ * ```js
270
+ * // In your root component:
271
+ * route.interceptLinks();
272
+ *
273
+ * // Now you can use regular anchor tags:
274
+ * $('a text=About href=/corporate/about');
275
+ * ```
276
+ */
277
+ export function interceptLinks() {
278
+ $({
279
+ click: handleEvent,
280
+ keydown: handleKeyEvent,
281
+ });
282
+
283
+ function handleKeyEvent(e: KeyboardEvent) {
284
+ if (e.key === "Enter") {
285
+ handleEvent(e);
286
+ }
287
+ }
288
+
289
+ function handleEvent(e: Event) {
290
+ // Find the closest <a> tag
291
+ let target = e.target as HTMLElement | null;
292
+ while (target && target.tagName?.toUpperCase() !== "A") {
293
+ target = target.parentElement;
294
+ }
295
+
296
+ if (!target) return;
297
+
298
+ const anchor = target as HTMLAnchorElement;
299
+ const href = anchor.getAttribute("href");
300
+
301
+ if (!href) return;
302
+
303
+ // Skip hash-only links
304
+ if (href.startsWith("#")) return;
305
+
306
+ // Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
307
+ if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
308
+
309
+ // Skip if the link has target or download attribute
310
+ if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
311
+
312
+ // Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
313
+ if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
314
+
315
+ e.preventDefault();
316
+
317
+ // Parse using URL to handle both absolute and relative paths correctly
318
+ const url = new URL(href, locationE.href);
319
+ go({
320
+ path: url.pathname,
321
+ search: Object.fromEntries(url.searchParams),
322
+ hash: url.hash,
323
+ });
324
+ }
325
+ }
326
+
256
327
  let prevStack: string[];
257
328
 
258
329
  /**
@@ -265,7 +336,7 @@ export const current: Route = proxy({}) as Route;
265
336
  * @internal
266
337
  * */
267
338
  export function reset() {
268
- prevStack = history.state?.stack || [];
339
+ prevStack = historyE.state?.stack || [];
269
340
  const initRoute = getRouteFromBrowser();
270
341
  log('initial', initRoute);
271
342
  copy(unproxy(current), initRoute);
@@ -273,12 +344,12 @@ export function reset() {
273
344
  reset();
274
345
 
275
346
  // Handle browser history back and forward
276
- window.addEventListener("popstate", function(event: PopStateEvent) {
347
+ windowE.addEventListener("popstate", function(event: PopStateEvent) {
277
348
  const newRoute = getRouteFromBrowser();
278
349
 
279
350
  // If the stack length changes, and at least the top-most shared entry is the same,
280
351
  // we'll interpret this as a "back" or "forward" navigation.
281
- const stack: string[] = history.state?.stack || [];
352
+ const stack: string[] = historyE.state?.stack || [];
282
353
  if (stack.length !== prevStack.length) {
283
354
  const maxIndex = Math.min(prevStack.length, stack.length) - 1;
284
355
  if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
@@ -306,16 +377,16 @@ leakScope(() => {
306
377
  $(() => {
307
378
 
308
379
  // First normalize `route`
309
- const stack = history.state?.stack || [];
380
+ const stack = historyE.state?.stack || [];
310
381
  const newRoute = toCanonRoute(current, unproxy(current).nav, stack.length + 1);
311
382
  copy(current, newRoute);
312
383
 
313
384
  // Then replace the current browser state if something actually changed
314
385
  const state = {state: newRoute.state, stack};
315
386
  const url = getUrl(newRoute);
316
- if (url !== location.pathname + location.search + location.hash || !equal(history.state, state, false)) {
387
+ if (url !== locationE.pathname + locationE.search + locationE.hash || !equal(historyE.state, state, false)) {
317
388
  log('replaceState', newRoute, state, url);
318
- history.replaceState(state, "", url);
389
+ historyE.replaceState(state, "", url);
319
390
  }
320
391
  });
321
392
  });