compact-router 0.0.0-stage → 0.1.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/src/Route.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { QuasiURL } from "quasiurl";
2
+ import type { LinkElement } from "./types/LinkElement.ts";
3
+ import type { LocationPattern } from "./types/LocationPattern.ts";
4
+ import type { LocationValue } from "./types/LocationValue.ts";
5
+ import type { MatchHandler } from "./types/MatchHandler.ts";
6
+ import type { NavigationOptions } from "./types/NavigationOptions.ts";
7
+ import type { URLData } from "./types/URLData.ts";
8
+ import {
9
+ URLState,
10
+ type URLStateOptions,
11
+ type URLStatePayloadMap,
12
+ } from "./URLState.ts";
13
+ import { compileURL } from "./utils/compileURL.ts";
14
+ import { getNavigationOptions } from "./utils/getNavigationOptions.ts";
15
+ import { isRouteEvent } from "./utils/isRouteEvent.ts";
16
+ import { matchURL } from "./utils/matchURL.ts";
17
+
18
+ export type ContainerElement = Document | Element | null | undefined;
19
+ export type ElementCollection = (string | Node)[] | HTMLCollection | NodeList;
20
+
21
+ export type ObservedElement =
22
+ | string
23
+ | Node
24
+ | (string | Node)[]
25
+ | HTMLCollection
26
+ | NodeList;
27
+
28
+ function isElementCollection(x: unknown): x is ElementCollection {
29
+ return (
30
+ Array.isArray(x) || x instanceof NodeList || x instanceof HTMLCollection
31
+ );
32
+ }
33
+
34
+ function isLinkElement(x: unknown): x is LinkElement {
35
+ return x instanceof HTMLAnchorElement || x instanceof HTMLAreaElement;
36
+ }
37
+
38
+ function toggleActive(element: Element | Node, route: Route) {
39
+ if (!isLinkElement(element)) return;
40
+
41
+ if (
42
+ element.hasAttribute("href") &&
43
+ route.match(route.toValue(element.href)).ok
44
+ )
45
+ element.dataset.active = "true";
46
+ else delete element.dataset.active;
47
+ }
48
+
49
+ export type RoutePayloadMap = URLStatePayloadMap & {
50
+ documentclick: MouseEvent;
51
+ };
52
+
53
+ export type RouteOptions = URLStateOptions;
54
+
55
+ export class Route extends URLState<RoutePayloadMap> {
56
+ _observeInited = false;
57
+ constructor(href: LocationValue | null = null, options?: RouteOptions) {
58
+ super(String(href ?? ""), options);
59
+ }
60
+ /**
61
+ * Enables SPA navigation with HTML links inside the specified container.
62
+ *
63
+ * @example
64
+ * ```js
65
+ * let route = new Route();
66
+ * route.observe(document);
67
+ * ```
68
+ */
69
+ observe(
70
+ container: ContainerElement | (() => ContainerElement),
71
+ elements: ObservedElement = "a, area",
72
+ ) {
73
+ if (typeof window === "undefined") return;
74
+
75
+ if (!this._observeInited) {
76
+ let handleClick = (event: MouseEvent) => {
77
+ this.emit("documentclick", event);
78
+ };
79
+
80
+ this.on("start", () => {
81
+ document.addEventListener("click", handleClick);
82
+ });
83
+
84
+ this.on("stop", () => {
85
+ document.removeEventListener("click", handleClick);
86
+ });
87
+
88
+ if (this.active) document.addEventListener("click", handleClick);
89
+
90
+ this._observeInited = true;
91
+ }
92
+
93
+ let resolveParams = () => {
94
+ let resolvedContainer =
95
+ typeof container === "function" ? container() : container;
96
+
97
+ if (!resolvedContainer) return;
98
+
99
+ let targetElements = isElementCollection(elements)
100
+ ? Array.from(elements)
101
+ : [elements];
102
+
103
+ return { resolvedContainer, targetElements };
104
+ };
105
+
106
+ let removeClickHandlers = this.on("documentclick", (event: MouseEvent) => {
107
+ if (event.defaultPrevented || !isRouteEvent(event)) return;
108
+
109
+ let resolvedParams = resolveParams();
110
+
111
+ if (!resolvedParams) return;
112
+
113
+ let { resolvedContainer, targetElements } = resolvedParams;
114
+ let element: HTMLAnchorElement | HTMLAreaElement | null = null;
115
+
116
+ for (let targetElement of targetElements) {
117
+ let target: Node | null = null;
118
+
119
+ if (typeof targetElement === "string")
120
+ target =
121
+ event.target instanceof HTMLElement
122
+ ? event.target.closest(targetElement)
123
+ : null;
124
+ else target = targetElement;
125
+
126
+ if (isLinkElement(target) && resolvedContainer.contains(target)) {
127
+ element = target;
128
+ break;
129
+ }
130
+ }
131
+
132
+ if (element) {
133
+ event.preventDefault();
134
+ this.navigate(getNavigationOptions(element));
135
+ }
136
+ });
137
+
138
+ let removeURLChangeHandlers = this.on("ready", () => {
139
+ let resolvedParams = resolveParams();
140
+
141
+ if (!resolvedParams) return;
142
+
143
+ let { resolvedContainer, targetElements } = resolvedParams;
144
+
145
+ for (let targetElement of targetElements) {
146
+ if (typeof targetElement === "string") {
147
+ let targets = resolvedContainer.querySelectorAll(targetElement);
148
+
149
+ for (let element of targets) toggleActive(element, this);
150
+ } else if (resolvedContainer.contains(targetElement))
151
+ toggleActive(targetElement, this);
152
+ }
153
+ });
154
+
155
+ return () => {
156
+ removeClickHandlers();
157
+ removeURLChangeHandlers();
158
+ };
159
+ }
160
+ /**
161
+ * Navigates to the URL specified with `options.href`.
162
+ *
163
+ * @example
164
+ * ```js
165
+ * let route = new Route();
166
+ * route.navigate({ href: "/intro", history: "replace", scroll: "off" });
167
+ * ```
168
+ */
169
+ navigate(options?: NavigationOptions<LocationValue>): void {
170
+ if (!options?.href) return;
171
+
172
+ let { href, referrer, ...params } = options;
173
+
174
+ // Stringify `LocationValue` URLs in `options`
175
+ let transformedOptions = {
176
+ href: String(href),
177
+ referrer: referrer && String(referrer),
178
+ ...params,
179
+ };
180
+
181
+ this.setValue(transformedOptions.href, transformedOptions);
182
+ }
183
+ assign(url: LocationValue) {
184
+ this.navigate({ href: url });
185
+ }
186
+ replace(url: LocationValue) {
187
+ this.navigate({ href: url, history: "replace" });
188
+ }
189
+ reload() {
190
+ this.assign(this.getValue());
191
+ }
192
+ go(delta: number) {
193
+ if (typeof window !== "undefined" && window.history)
194
+ window.history.go(delta);
195
+ }
196
+ back() {
197
+ this.go(-1);
198
+ }
199
+ forward() {
200
+ this.go(1);
201
+ }
202
+ get href(): string {
203
+ return this.getValue();
204
+ }
205
+ set href(value: LocationValue) {
206
+ this.assign(value);
207
+ }
208
+ get pathname(): string {
209
+ return new QuasiURL(this.href).pathname;
210
+ }
211
+ set pathname(value: LocationValue) {
212
+ let url = new QuasiURL(this.href);
213
+ url.pathname = String(value);
214
+ this.assign(url.href);
215
+ }
216
+ get search(): string {
217
+ return new QuasiURL(this.href).search;
218
+ }
219
+ set search(value: string | URLSearchParams) {
220
+ let url = new QuasiURL(this.href);
221
+ url.search = value;
222
+ this.assign(url.href);
223
+ }
224
+ get hash() {
225
+ return new QuasiURL(this.href).hash;
226
+ }
227
+ set hash(value: string) {
228
+ let url = new QuasiURL(this.href);
229
+ url.hash = value;
230
+ this.assign(url.href);
231
+ }
232
+ toString() {
233
+ return this.href;
234
+ }
235
+ /**
236
+ * Matches the current location against `urlPattern`.
237
+ */
238
+ match<P extends LocationPattern>(urlPattern: P) {
239
+ return matchURL<P>(urlPattern, this.href);
240
+ }
241
+ /**
242
+ * Compiles `urlPattern` to a URL string by filling out the parameters
243
+ * based on `data`.
244
+ */
245
+ compile<T extends LocationValue>(urlPattern: T, data?: URLData<T>) {
246
+ return compileURL<T>(urlPattern, data);
247
+ }
248
+ /**
249
+ * `at(urlPattern)` returns `true` if `urlPattern` matches the current URL,
250
+ * and `false` otherwise.
251
+ *
252
+ * `at(urlPattern, x, y?)` returns either based on `x` if `urlPattern`
253
+ * matches the current URL, or based on `y` otherwise. (It loosely resembles
254
+ * the ternary conditional operator `matchesURLPattern ? x : y`.)
255
+ *
256
+ * If the current location matches `urlPattern`, `at(urlPattern, x, y)`
257
+ * returns:
258
+ * - `x`, if `x` is not a function;
259
+ * - `x({ params })`, if `x` is a function, with `params` extracted from
260
+ * the current URL.
261
+ *
262
+ * If the current location doesn't match `urlPattern`, `at(urlPattern, x, y)`
263
+ * returns:
264
+ * - `y`, if `y` is not a function;
265
+ * - `y({ params })`, if `y` is a function, with `params` extracted from
266
+ * the current URL.
267
+ */
268
+ at<P extends LocationPattern>(urlPattern: P): boolean;
269
+
270
+ at<P extends LocationPattern, X>(
271
+ urlPattern: P,
272
+ x: X | MatchHandler<P, X>,
273
+ ): X | undefined;
274
+
275
+ at<P extends LocationPattern, X, Y>(
276
+ urlPattern: P,
277
+ x: X | MatchHandler<P, X>,
278
+ y: Y | MatchHandler<P, Y>,
279
+ ): X | Y;
280
+
281
+ at<P extends LocationPattern, X, Y>(
282
+ urlPattern: P,
283
+ x?: X | MatchHandler<P, X>,
284
+ y?: Y | MatchHandler<P, Y>,
285
+ ): X | Y | boolean | undefined {
286
+ let result = this.match<P>(urlPattern);
287
+
288
+ if (x === undefined && y === undefined) return result.ok;
289
+
290
+ if (!result.ok)
291
+ return typeof y === "function" ? (y as MatchHandler<P, Y>)(result) : y;
292
+
293
+ return typeof x === "function" ? (x as MatchHandler<P, X>)(result) : x;
294
+ }
295
+ }
@@ -0,0 +1,161 @@
1
+ import {
2
+ type EventCallback,
3
+ State,
4
+ type StateOptions,
5
+ type StatePayloadMap,
6
+ type StateUpdate,
7
+ } from "compact-state";
8
+ import { QuasiURL } from "quasiurl";
9
+ import type { NavigationOptions } from "./types/NavigationOptions.ts";
10
+
11
+ export type URLStatePayloadMap = StatePayloadMap<string> & {
12
+ navigationstart: NavigationOptions;
13
+ // Similar to the "update" event, but with a `NavigationOptions` payload
14
+ navigation: NavigationOptions;
15
+ navigationcomplete: NavigationOptions;
16
+ ready: void;
17
+ };
18
+
19
+ function isImmediatelyInvokedNavigationEvent(
20
+ event: unknown,
21
+ ): event is "navigationstart" | "navigationcomplete" {
22
+ return event === "navigationstart" || event === "navigationcomplete";
23
+ }
24
+
25
+ function isImmediatelyInvokedEvent(event: unknown): event is "ready" {
26
+ return event === "ready";
27
+ }
28
+
29
+ export type URLStateOptions = StateOptions;
30
+
31
+ export class URLState<
32
+ P extends URLStatePayloadMap = URLStatePayloadMap,
33
+ > extends State<string, P> {
34
+ constructor(href: string | null = null, options?: URLStateOptions) {
35
+ super(href ?? "", options);
36
+ }
37
+ _init() {
38
+ super._init();
39
+
40
+ if (typeof window === "undefined") return;
41
+
42
+ let handleURLChange = () => {
43
+ this.setValue(window.location.href, { source: "popstate" });
44
+ };
45
+
46
+ this.on("start", () => {
47
+ window.addEventListener("popstate", handleURLChange);
48
+ this.emit("ready");
49
+ });
50
+
51
+ this.on("stop", () => {
52
+ window.removeEventListener("popstate", handleURLChange);
53
+ });
54
+ }
55
+ on<E extends string>(
56
+ event: E,
57
+ callback: EventCallback<P[E]>,
58
+ invokeImmediately?: boolean,
59
+ ) {
60
+ if (invokeImmediately !== false) {
61
+ if (isImmediatelyInvokedNavigationEvent(event))
62
+ this._call(() => {
63
+ callback({ href: this.getValue() } as P[typeof event]);
64
+ });
65
+ else if (isImmediatelyInvokedEvent(event))
66
+ this._call(() => {
67
+ (callback as EventCallback<void>)();
68
+ });
69
+ }
70
+
71
+ return super.on(event, callback);
72
+ }
73
+ getValue() {
74
+ return this.toValue(this._value);
75
+ }
76
+ setValue(update: string | StateUpdate<string>, options?: NavigationOptions) {
77
+ if (!this._active) return;
78
+
79
+ let href = this.toValue(this._resolveValue(update));
80
+
81
+ let extendedOptions: NavigationOptions = {
82
+ ...options,
83
+ href,
84
+ referrer: this.getValue(),
85
+ };
86
+
87
+ if (
88
+ this.emit("navigationstart", extendedOptions) &&
89
+ this._transition(extendedOptions) !== false
90
+ ) {
91
+ this._assignValue(href);
92
+ this.emit("navigation", extendedOptions);
93
+
94
+ if (this.emit("navigationcomplete", extendedOptions)) {
95
+ this._complete(extendedOptions);
96
+ this.emit("ready");
97
+ }
98
+ }
99
+ }
100
+ _transition(options?: NavigationOptions): boolean | void | undefined {
101
+ if (
102
+ typeof window === "undefined" ||
103
+ options?.href === undefined ||
104
+ options?.source === "popstate"
105
+ )
106
+ return;
107
+
108
+ let { href, target, spa, history } = options;
109
+
110
+ if (target && target !== "_self") {
111
+ window.open(href, target);
112
+ return false;
113
+ }
114
+
115
+ let url = new QuasiURL(href);
116
+
117
+ if (
118
+ spa === "off" ||
119
+ !window.history ||
120
+ (url.origin !== "" && url.origin !== window.location.origin)
121
+ ) {
122
+ window.location[history === "replace" ? "replace" : "assign"](href);
123
+ return false;
124
+ }
125
+
126
+ window.history[history === "replace" ? "replaceState" : "pushState"](
127
+ {},
128
+ "",
129
+ href,
130
+ );
131
+ }
132
+ _complete(options?: NavigationOptions): boolean | void | undefined {
133
+ if (typeof window === "undefined" || !options || options.scroll === "off")
134
+ return;
135
+
136
+ let { href, target } = options;
137
+
138
+ if (href === undefined || (target && target !== "_self")) return;
139
+
140
+ let { hash } = new QuasiURL(href);
141
+
142
+ requestAnimationFrame(() => {
143
+ let targetElement =
144
+ hash === ""
145
+ ? null
146
+ : document.querySelector(`${hash}, a[name="${hash.slice(1)}"]`);
147
+
148
+ if (targetElement) targetElement.scrollIntoView();
149
+ else window.scrollTo(0, 0);
150
+ });
151
+ }
152
+ toValue(x: string) {
153
+ if (typeof window === "undefined") return x;
154
+
155
+ let url = new QuasiURL(x || window.location.href);
156
+
157
+ if (url.origin === window.location.origin) url.origin = "";
158
+
159
+ return url.href;
160
+ }
161
+ }
@@ -0,0 +1 @@
1
+ export type LinkElement = HTMLAnchorElement | HTMLAreaElement;
@@ -0,0 +1,11 @@
1
+ import type { URLComponents } from "./URLComponents.ts";
2
+ import type { URLSchema } from "./URLSchema.ts";
3
+
4
+ // URL builder output
5
+ export type LocationObject = {
6
+ _schema: URLSchema | undefined;
7
+ exec: (x: string) => URLComponents | null;
8
+ // biome-ignore lint/suspicious/noExplicitAny: third-party
9
+ compile: (x: any) => string;
10
+ toString: () => string;
11
+ };
@@ -0,0 +1,6 @@
1
+ import type { LocationValue } from "./LocationValue.ts";
2
+ import type { URLConfig } from "./URLConfig.ts";
3
+
4
+ export type LocationPattern = URLConfig["strict"] extends true
5
+ ? LocationValue | LocationValue[]
6
+ : LocationValue | RegExp | (LocationValue | RegExp)[];
@@ -0,0 +1,6 @@
1
+ import type { LocationObject } from "./LocationObject.ts";
2
+ import type { URLConfig } from "./URLConfig.ts";
3
+
4
+ export type LocationValue = URLConfig["strict"] extends true
5
+ ? LocationObject | undefined
6
+ : string | LocationObject | undefined;
@@ -0,0 +1,6 @@
1
+ import type { LocationPattern } from "./LocationPattern.ts";
2
+ import type { MatchState } from "./MatchState.ts";
3
+
4
+ export type MatchHandler<P extends LocationPattern, T> = (
5
+ payload: MatchState<P>,
6
+ ) => T;
@@ -0,0 +1,22 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { LocationPattern } from "../types/LocationPattern.ts";
3
+ import type { URLSchema } from "../types/URLSchema.ts";
4
+
5
+ export type MatchState<P extends LocationPattern> = {
6
+ ok: boolean;
7
+ href: string;
8
+ params: P extends { _schema: URLSchema }
9
+ ? StandardSchemaV1.InferOutput<P["_schema"]> extends {
10
+ params?: Record<string, unknown>;
11
+ }
12
+ ? StandardSchemaV1.InferOutput<P["_schema"]>["params"]
13
+ : Record<string, never>
14
+ : Record<string, string | undefined>;
15
+ query: P extends { _schema: URLSchema }
16
+ ? StandardSchemaV1.InferOutput<P["_schema"]> extends {
17
+ query?: Record<string, unknown>;
18
+ }
19
+ ? StandardSchemaV1.InferOutput<P["_schema"]>["query"]
20
+ : Record<string, never>
21
+ : Record<string, string | undefined>;
22
+ };
@@ -0,0 +1,10 @@
1
+ export type NavigationOptions<Href = string> = {
2
+ target?: string;
3
+ href?: Href;
4
+ referrer?: Href;
5
+ spa?: "auto" | "off" | undefined;
6
+ history?: "push" | "replace" | undefined;
7
+ scroll?: "auto" | "off" | undefined;
8
+ id?: string;
9
+ source?: "popstate" | undefined;
10
+ };
@@ -0,0 +1,4 @@
1
+ export type URLComponents = {
2
+ params?: Record<string, unknown> | undefined;
3
+ query?: Record<string, unknown> | undefined;
4
+ };
@@ -0,0 +1,5 @@
1
+ type URLConfigShape = {
2
+ strict?: boolean;
3
+ };
4
+
5
+ export interface URLConfig extends URLConfigShape {}
@@ -0,0 +1,10 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { LocationValue } from "./LocationValue.ts";
3
+ import type { URLComponents } from "./URLComponents.ts";
4
+ import type { URLSchema } from "./URLSchema.ts";
5
+
6
+ export type URLData<T extends LocationValue = LocationValue> = T extends {
7
+ _schema: URLSchema;
8
+ }
9
+ ? StandardSchemaV1.InferOutput<T["_schema"]>
10
+ : URLComponents;
@@ -0,0 +1,4 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { URLComponents } from "./URLComponents.ts";
3
+
4
+ export type URLSchema = StandardSchemaV1<URLComponents>;
@@ -0,0 +1,27 @@
1
+ import { QuasiURL } from "quasiurl";
2
+ import type { LocationValue } from "../types/LocationValue.ts";
3
+ import type { URLData } from "../types/URLData.ts";
4
+ import { isLocationObject } from "./isLocationObject.ts";
5
+
6
+ export function compileURL<T extends LocationValue>(
7
+ urlPattern: T,
8
+ data?: URLData<T>,
9
+ ) {
10
+ if (isLocationObject(urlPattern)) return urlPattern.compile(data);
11
+
12
+ let url = new QuasiURL(urlPattern ?? "");
13
+ let query = data?.query;
14
+
15
+ if (query) {
16
+ url.search = new URLSearchParams(
17
+ // Remove null und undefined values, stringify nonstring values
18
+ Object.entries(query).reduce<Record<string, string>>((p, [k, v]) => {
19
+ if (v !== null && v !== undefined)
20
+ p[k] = typeof v === "string" ? v : JSON.stringify(v);
21
+ return p;
22
+ }, {}),
23
+ );
24
+ }
25
+
26
+ return url.href;
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { LinkElement } from "../types/LinkElement.ts";
2
+ import type { NavigationOptions } from "../types/NavigationOptions.ts";
3
+
4
+ export function getNavigationOptions(element: LinkElement) {
5
+ let { id, spa, history, scroll } = element.dataset;
6
+
7
+ return {
8
+ href: element.getAttribute("href"),
9
+ target: element.getAttribute("target"),
10
+ spa,
11
+ history,
12
+ scroll,
13
+ id,
14
+ } as NavigationOptions;
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { LocationObject } from "../types/LocationObject.ts";
2
+
3
+ export function isLocationObject(x: unknown): x is LocationObject {
4
+ return (
5
+ x !== null &&
6
+ typeof x === "object" &&
7
+ "exec" in x &&
8
+ "compile" in x &&
9
+ "_schema" in x
10
+ );
11
+ }
@@ -0,0 +1,11 @@
1
+ export function isRouteEvent(event: unknown) {
2
+ return (
3
+ event !== null &&
4
+ typeof event === "object" &&
5
+ (!("button" in event) || event.button === 0) &&
6
+ (!("ctrlKey" in event) || !event.ctrlKey) &&
7
+ (!("shiftKey" in event) || !event.shiftKey) &&
8
+ (!("altKey" in event) || !event.altKey) &&
9
+ (!("metaKey" in event) || !event.metaKey)
10
+ );
11
+ }