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/README.md +22 -22
- package/dist/aberdeen.d.ts +173 -129
- package/dist/aberdeen.js +188 -95
- package/dist/aberdeen.js.map +3 -3
- package/dist/dispatcher.d.ts +10 -7
- package/dist/dispatcher.js +11 -10
- package/dist/dispatcher.js.map +3 -3
- package/dist/route.d.ts +17 -0
- package/dist/route.js +62 -20
- package/dist/route.js.map +3 -3
- package/dist-min/aberdeen.js +9 -7
- package/dist-min/aberdeen.js.map +3 -3
- package/dist-min/dispatcher.js +2 -2
- package/dist-min/dispatcher.js.map +3 -3
- package/dist-min/route.js +2 -2
- package/dist-min/route.js.map +3 -3
- package/html-to-aberdeen +3 -6
- package/package.json +1 -1
- package/skill/SKILL.md +286 -76
- package/skill/aberdeen.md +219 -203
- package/skill/dispatcher.md +16 -13
- package/skill/prediction.md +3 -3
- package/skill/route.md +44 -16
- package/skill/transitions.md +3 -3
- package/src/aberdeen.ts +403 -237
- package/src/dispatcher.ts +16 -13
- package/src/route.ts +90 -19
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
|
|
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
|
|
9
|
+
export const MATCH_REST: unique symbol = Symbol("MATCH_REST");
|
|
10
10
|
|
|
11
|
-
type Matcher = string | ((segment: string) => any) | typeof
|
|
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
|
|
17
|
-
: (M extends typeof
|
|
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",
|
|
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
|
|
66
|
-
* - The special {@link
|
|
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 ===
|
|
81
|
+
const restCount = matchers.filter(m => m === MATCH_REST).length;
|
|
79
82
|
if (restCount > 1) {
|
|
80
|
-
throw new Error("Only one
|
|
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 ===
|
|
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 ===
|
|
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,
|
|
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:
|
|
50
|
-
hash:
|
|
51
|
-
search: Object.fromEntries(new URLSearchParams(
|
|
52
|
-
state:
|
|
53
|
-
}, "load", (
|
|
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[] =
|
|
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
|
-
|
|
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[] =
|
|
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
|
-
|
|
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[] =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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[] =
|
|
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 =
|
|
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 !==
|
|
387
|
+
if (url !== locationE.pathname + locationE.search + locationE.hash || !equal(historyE.state, state, false)) {
|
|
317
388
|
log('replaceState', newRoute, state, url);
|
|
318
|
-
|
|
389
|
+
historyE.replaceState(state, "", url);
|
|
319
390
|
}
|
|
320
391
|
});
|
|
321
392
|
});
|