@snapstrat/switchboard 1.0.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.
@@ -0,0 +1,43 @@
1
+ <svelte:options runes={true} />
2
+ <script lang="ts" module>
3
+ import type { Snippet } from 'svelte';
4
+ import type { RouteContainer } from './router.svelte';
5
+ export type Route404Props = {
6
+ children: Snippet;
7
+ container?: RouteContainer;
8
+
9
+ }
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ import { onDestroy, onMount } from 'svelte';
14
+ import { type ApplicationRoute, getLayout, getRouteContainer, getRouter, type LayoutData, RoutePath } from '..';
15
+
16
+ let { children, container }: Route404Props = $props();
17
+
18
+ let router = getRouter();
19
+
20
+ let route : ApplicationRoute | undefined;
21
+
22
+ onMount(() => {
23
+ const layout = getLayout();
24
+
25
+ container ??= getRouteContainer();
26
+
27
+ const layoutPath = layout?.joinedPath ?? '';
28
+
29
+ route = {
30
+ path: RoutePath.fromString(layoutPath, true),
31
+ component: children,
32
+ layout: container.isRouter() ? undefined : container as LayoutData
33
+ };
34
+ container.registerRoute404(route);
35
+ });
36
+
37
+ onDestroy(() => {
38
+ if (route) {
39
+ const container = getLayout() ?? router;
40
+ container.unregisterRoute(route);
41
+ }
42
+ })
43
+ </script>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { RouteContainer } from './router.svelte';
3
+ export type Route404Props = {
4
+ children: Snippet;
5
+ container?: RouteContainer;
6
+ };
7
+ declare const Route404: import("svelte").Component<Route404Props, {}, "">;
8
+ type Route404 = ReturnType<typeof Route404>;
9
+ export default Route404;
@@ -0,0 +1 @@
1
+ export * from "./webRouter.svelte";
@@ -0,0 +1 @@
1
+ export * from "./webRouter.svelte";
@@ -0,0 +1,7 @@
1
+ import { type Router, type RouterOptions } from '../..';
2
+ /**
3
+ * Creates a {@link Router} that interfaces with the Web Browser's history API.
4
+ *
5
+ * @param options Options for the router, such as base path or initial route.
6
+ */
7
+ export declare const createWebRouter: (options?: RouterOptions) => Router;
@@ -0,0 +1,172 @@
1
+ import { RouteParam, RoutePath, Route404Path, } from '../..';
2
+ import { createSelector, parseWindowSearchParams } from '../internals/windowUtils';
3
+ import { tick } from 'svelte';
4
+ /**
5
+ * A router used for an entire application on the web.
6
+ */
7
+ class WebRouter {
8
+ _routes = [];
9
+ _routes404 = [];
10
+ // routes are stored in reverse order for easier matching
11
+ // the most recently added routes are matched first
12
+ get routes() {
13
+ return this._routes.toReversed();
14
+ }
15
+ get routes404() {
16
+ return this._routes404.toReversed();
17
+ }
18
+ // this is undefined for a short time during initialization,
19
+ // after tick() it's always defined.
20
+ currentRoute = $state.raw(undefined);
21
+ options;
22
+ constructor(options) {
23
+ this.options = Object.freeze(options);
24
+ // we want to listen to popstate events to update the current route
25
+ // in this router
26
+ window.addEventListener('popstate', async (ev) => {
27
+ ev.preventDefault();
28
+ this.switchTo(window.location.pathname + window.location.search, {}, false);
29
+ await tick();
30
+ // restore focused element if we have one
31
+ const state = ev.state;
32
+ if (state?.focusedElement) {
33
+ const element = document.querySelector(state.focusedElement);
34
+ if (element) {
35
+ element.focus();
36
+ }
37
+ }
38
+ });
39
+ }
40
+ get params() {
41
+ return this.currentRoute?.queryParams ?? {};
42
+ }
43
+ get queryParams() {
44
+ return this.currentRoute?.queryParams ?? {};
45
+ }
46
+ getRouteParam(name) {
47
+ return this.currentRoute.params[name];
48
+ }
49
+ getQueryParam(name) {
50
+ return this.currentRoute.queryParams[name];
51
+ }
52
+ refresh() {
53
+ this.switchTo(this.currentRoute.path, this.currentRoute.queryParams, true);
54
+ }
55
+ switchTo(path, queryParams = {}, pushNewState = true) {
56
+ const getQueryParam = queryParams instanceof Map ? (k) => queryParams.get(k) : (k) => queryParams[k];
57
+ const queryParamKeys = queryParams instanceof Map ? [...queryParams.keys()] : Object.keys(queryParams);
58
+ // create a url to easily manipulate the route params
59
+ const url = new URL(window.location.origin + path);
60
+ const route = this.getRoute(url.pathname);
61
+ // set the query params in the url to the new ones
62
+ for (const key of queryParamKeys) {
63
+ url.searchParams.set(key, getQueryParam(key));
64
+ }
65
+ path = url.pathname;
66
+ // create the new active route object
67
+ const params = this.createParams(path);
68
+ const queryParamsMap = {};
69
+ url.searchParams.forEach((value, key) => {
70
+ queryParamsMap[key] = value;
71
+ });
72
+ const selectedRoute = {
73
+ route,
74
+ params,
75
+ path: url.pathname,
76
+ queryParams: queryParamsMap
77
+ };
78
+ const state = {
79
+ focusedElement: document.activeElement
80
+ ? createSelector(document.activeElement)
81
+ : undefined,
82
+ };
83
+ // only used for changing focused element
84
+ window.history.replaceState(state, '');
85
+ if (pushNewState)
86
+ window.history.pushState({}, '', url);
87
+ this.currentRoute = selectedRoute;
88
+ return selectedRoute;
89
+ }
90
+ registerRoute(route) {
91
+ this._routes.push(route);
92
+ }
93
+ registerRoute404(route) {
94
+ this._routes404.push(route);
95
+ // switch to the new route if it's a 404 route and the current route is undefined
96
+ if (this.currentRoute?.route == undefined) {
97
+ this.switchTo(window.location.pathname, parseWindowSearchParams(), false);
98
+ }
99
+ }
100
+ /**
101
+ * Get a route by its path. Accounts for route parameters.
102
+ * @param path
103
+ */
104
+ getRoute(path) {
105
+ if (path == '/') {
106
+ return this.routes.find((route) => route.path.parts.length === 0) ?? this.getBase404();
107
+ }
108
+ // find the route that matches the path
109
+ const routes = this.findBestFit(path);
110
+ return routes ?? this.getClosest404(path);
111
+ }
112
+ findBestFit(path) {
113
+ return this.routes.find((route) => {
114
+ return route.path.matches(path);
115
+ });
116
+ }
117
+ getBase404() {
118
+ // return the global 404 route
119
+ return this.routes404.find((route) => route.path instanceof Route404Path && route.path.parts.length == 0);
120
+ }
121
+ getClosest404(path) {
122
+ // get the path minus the last segment
123
+ const splitPath = RoutePath.normalizePath(path).split('/').slice(0, -1);
124
+ for (let i = splitPath.length; i >= 0; i--) {
125
+ const subPath = '/' + splitPath.slice(0, i).join('/');
126
+ const route = this.routes404.find((route) => route.path.matches(subPath));
127
+ if (route) {
128
+ return route;
129
+ }
130
+ }
131
+ return this.getBase404();
132
+ }
133
+ // extract the route (not query) params from the path
134
+ createParams(path) {
135
+ if (path == '/')
136
+ return {};
137
+ const route = this.getRoute(path);
138
+ if (route == undefined) {
139
+ throw new Error(`Route not found for path: ${path}`);
140
+ }
141
+ const parts = RoutePath.normalizePath(path).split('/');
142
+ const params = {};
143
+ route.path.parts.forEach((part, index) => {
144
+ if (part instanceof RouteParam) {
145
+ params[part.name] = parts[index];
146
+ }
147
+ });
148
+ return params;
149
+ }
150
+ unregisterRoute(route) {
151
+ const toRemove = this._routes.indexOf(route);
152
+ if (toRemove == -1)
153
+ return;
154
+ if (this.currentRoute?.route == route) {
155
+ // switch to what will now be a 404 page if we're currently here
156
+ this.switchTo(this.currentRoute.path, parseWindowSearchParams(), false);
157
+ }
158
+ this._routes.splice(toRemove, 1);
159
+ }
160
+ getAllRoutes() {
161
+ return [...this.routes];
162
+ }
163
+ isRouter() { return true; }
164
+ }
165
+ /**
166
+ * Creates a {@link Router} that interfaces with the Web Browser's history API.
167
+ *
168
+ * @param options Options for the router, such as base path or initial route.
169
+ */
170
+ export const createWebRouter = (options = {}) => {
171
+ return new WebRouter(options);
172
+ };
@@ -0,0 +1,13 @@
1
+ export { default as Link } from './Link.svelte';
2
+ export * from './Link.svelte';
3
+ export { default as BrowserRouter } from './BrowserRouter.svelte';
4
+ export * from './BrowserRouter.svelte';
5
+ export { default as Route404 } from './Route404.svelte';
6
+ export * from './Route404.svelte';
7
+ export { default as Route } from './Route.svelte';
8
+ export * from './Route.svelte';
9
+ export { default as PageInfo } from './PageInfo.svelte';
10
+ export * from './PageInfo.svelte';
11
+ export { default as Layout } from './Layout.svelte';
12
+ export * from './Layout.svelte';
13
+ export * from './router.svelte';
@@ -0,0 +1,13 @@
1
+ export { default as Link } from './Link.svelte';
2
+ export * from './Link.svelte';
3
+ export { default as BrowserRouter } from './BrowserRouter.svelte';
4
+ export * from './BrowserRouter.svelte';
5
+ export { default as Route404 } from './Route404.svelte';
6
+ export * from './Route404.svelte';
7
+ export { default as Route } from './Route.svelte';
8
+ export * from './Route.svelte';
9
+ export { default as PageInfo } from './PageInfo.svelte';
10
+ export * from './PageInfo.svelte';
11
+ export { default as Layout } from './Layout.svelte';
12
+ export * from './Layout.svelte';
13
+ export * from './router.svelte';
@@ -0,0 +1,2 @@
1
+ export declare function parseWindowSearchParams(): Record<string, string>;
2
+ export declare function createSelector(element: HTMLElement): string;
@@ -0,0 +1,29 @@
1
+ export function parseWindowSearchParams() {
2
+ const params = {};
3
+ new URL(window.location.href).searchParams.forEach((value, key) => {
4
+ params[key] = value;
5
+ });
6
+ return params;
7
+ }
8
+ export function createSelector(element) {
9
+ const { tagName, id, className, parentNode } = element;
10
+ if (tagName === 'HTML')
11
+ return 'HTML';
12
+ let str = tagName;
13
+ str += (id !== '') ? `#${id}` : '';
14
+ if (className) {
15
+ const classes = className.split(/\s/);
16
+ for (let i = 0; i < classes.length; i++) {
17
+ str += `.${classes[i]}`;
18
+ }
19
+ }
20
+ let childIndex = 1;
21
+ for (let e = element; e.previousElementSibling; e = e.previousElementSibling) {
22
+ childIndex += 1;
23
+ }
24
+ str += `:nth-child(${childIndex})`;
25
+ if (!parentNode) {
26
+ return str;
27
+ }
28
+ return `${createSelector(parentNode)} > ${str}`;
29
+ }
@@ -0,0 +1,178 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { Attachment } from 'svelte/attachments';
3
+ export type RoutePart = string | RouteParam;
4
+ export declare class RouteParam {
5
+ readonly name: string;
6
+ constructor(name: string);
7
+ }
8
+ /**
9
+ * A full path in the router.
10
+ * Can be created from a string, or from parts.
11
+ *
12
+ * Examples with created parts array:
13
+ * ```js
14
+ * RoutePath.fromString('/users/:userId') // ['users', RouteParam('userId')]
15
+ * RoutePath.fromParts(['users', new RouteParam('userId')]) // ['users', RouteParam('userId')]
16
+ * RoutePath.create404() // 404 route, no parts
17
+ * ```
18
+ */
19
+ export declare class RoutePath {
20
+ readonly parts: readonly RoutePart[];
21
+ protected constructor(parts: readonly RoutePart[]);
22
+ static fromString(path: string, is404?: boolean): RoutePath;
23
+ static fromParts(parts: readonly RoutePart[]): RoutePath;
24
+ static create404(layout?: readonly RoutePart[]): RoutePath;
25
+ static readonly concatPaths: (base: string, ...rest: string[]) => string;
26
+ /**
27
+ * Normalize a path by removing leading and trailing slashes.
28
+ * @param path The path to normalize.
29
+ */
30
+ static readonly normalizePath: (path: string) => string;
31
+ readonly matches: (path: string) => boolean;
32
+ }
33
+ export declare class Route404Path extends RoutePath {
34
+ readonly parts: readonly RoutePart[];
35
+ constructor(parts: readonly RoutePart[]);
36
+ }
37
+ /**
38
+ * A route in the application. This is not necessarily the route that is currently active.
39
+ */
40
+ export interface ApplicationRoute {
41
+ path: RoutePath;
42
+ layout?: LayoutData;
43
+ name?: string;
44
+ parents?: string[];
45
+ component?: Snippet;
46
+ }
47
+ export type RouterEvents = {
48
+ 'route:afterSwitch': (oldRoute: ActiveRoute | undefined, newRoute: ActiveRoute) => void;
49
+ };
50
+ export type RouterOptions = Partial<{
51
+ /**
52
+ * The default page title to be used when no specific title is set for a route.
53
+ */
54
+ defaultTitle: string;
55
+ basePath: string;
56
+ }>;
57
+ export declare function defaultRouterOptions(): RouterOptions;
58
+ /**
59
+ * A container for routes, either a router or a layout.
60
+ */
61
+ export interface RouteContainer {
62
+ /**
63
+ * Create a route.
64
+ * @param route The route to register.
65
+ * @param layout The layout to register the route under.
66
+ */
67
+ registerRoute(route: ApplicationRoute, layout?: LayoutData): void;
68
+ /**
69
+ * Create a 404 route.
70
+ * @param route The route to register as a 404 route.
71
+ * @param layout The layout to register the route under.
72
+ */
73
+ registerRoute404(route: ApplicationRoute, layout?: LayoutData): void;
74
+ /**
75
+ * Remove a route
76
+ * @param route The route to unregister.
77
+ * @param layout The layout to unregister the route from.
78
+ */
79
+ unregisterRoute(route: ApplicationRoute, layout?: LayoutData): void;
80
+ /**
81
+ * Check if this route container is a router.
82
+ *
83
+ * @returns True if this is a router, false otherwise.
84
+ */
85
+ isRouter(): this is Router;
86
+ }
87
+ /**
88
+ * A router used to switch between routes in an application.
89
+ */
90
+ export interface Router extends RouteContainer {
91
+ readonly options: RouterOptions;
92
+ /** Params specified in the route with : syntax, like /users/:userId */
93
+ params: RouteParams;
94
+ /** Query params specified after the route with ?, like /users/1234?showFullName=true */
95
+ queryParams: RouteParams;
96
+ /**
97
+ * Switch to a route.
98
+ * @param path
99
+ * @param queryParams
100
+ * @param pushNewState
101
+ */
102
+ switchTo(path: string, queryParams?: Record<string, string> | RouteParams, pushNewState?: boolean): ActiveRoute;
103
+ /**
104
+ * Get the current route.
105
+ */
106
+ currentRoute: ActiveRoute;
107
+ getRoute(path: string): ApplicationRoute;
108
+ /**
109
+ * Get all routes, including 404 routes.
110
+ */
111
+ getAllRoutes(): ApplicationRoute[];
112
+ createParams(path: string): RouteParams;
113
+ /**
114
+ * A quick refresh will not push a new state to the browser's history.
115
+ * @param quick
116
+ */
117
+ refresh(quick?: boolean): void;
118
+ /**
119
+ * Get a route parameter by name from the current route.
120
+ * @param name
121
+ */
122
+ getRouteParam(name: string): string;
123
+ /**
124
+ * Get a query parameter by name from the current route.
125
+ * @param name
126
+ */
127
+ getQueryParam(name: string): string | undefined;
128
+ }
129
+ export interface ActiveRoute {
130
+ /**
131
+ * The route object that is currently active.
132
+ */
133
+ route: ApplicationRoute;
134
+ /**
135
+ * Note: this may differ from route.path if the route is a 404 route.
136
+ */
137
+ path: string;
138
+ /** Params specified in the route with : syntax, like /users/:userId */
139
+ params: RouteParams;
140
+ /** Query params specified after the route with ?, like /users/1234?showFullName=true */
141
+ queryParams: RouteParams;
142
+ }
143
+ export type RouteParams = {
144
+ [key: string]: string;
145
+ };
146
+ export declare const ROUTER_CONTEXT_KEY = "switchboard::router";
147
+ export declare const LAYOUT_CONTEXT_KEY = "switchboard::layout";
148
+ export declare const ROUTE_NOT_FOUND_KEY = "__switchboard__404";
149
+ export type LayoutSnippet = Snippet<[Snippet]>;
150
+ export interface LayoutData extends RouteContainer {
151
+ path?: string;
152
+ parent?: LayoutData;
153
+ canonicalParent?: LayoutData;
154
+ renderer: LayoutSnippet;
155
+ notFoundRoute?: ApplicationRoute;
156
+ joinedPath: string;
157
+ }
158
+ export declare function getLayout(): LayoutData | undefined;
159
+ export declare function setLayoutContext(layout: LayoutData | undefined): void;
160
+ export declare function getRouter<T extends Router = Router>(): T;
161
+ export declare function getRouteParams(): RouteParams;
162
+ export declare function getQueryParams(): RouteParams;
163
+ export declare function setRouterContext(router: Router): void;
164
+ /**
165
+ * Gets the nearest route container, either a layout or the router.
166
+ */
167
+ export declare function getRouteContainer(): RouteContainer;
168
+ /**
169
+ * Create an attachment that sets the href of an anchor element
170
+ * and switches to the route when clicked.
171
+ * This is useful for creating links that work with the router, and won't cause a refresh when clicked.
172
+ * @param href The href to set on the anchor element.
173
+ *
174
+ * @see Link
175
+ */
176
+ export declare const href: (href: string) => Attachment<HTMLAnchorElement>;
177
+ export declare const getAllLayouts: (layout?: LayoutData) => LayoutData[];
178
+ export declare const getAllCanonicalLayouts: (layout?: LayoutData) => LayoutData[];
@@ -0,0 +1,167 @@
1
+ import { getContext, hasContext, setContext } from 'svelte';
2
+ // A dynamic route parameter.
3
+ export class RouteParam {
4
+ name;
5
+ constructor(name) {
6
+ this.name = name;
7
+ }
8
+ }
9
+ /**
10
+ * A full path in the router.
11
+ * Can be created from a string, or from parts.
12
+ *
13
+ * Examples with created parts array:
14
+ * ```js
15
+ * RoutePath.fromString('/users/:userId') // ['users', RouteParam('userId')]
16
+ * RoutePath.fromParts(['users', new RouteParam('userId')]) // ['users', RouteParam('userId')]
17
+ * RoutePath.create404() // 404 route, no parts
18
+ * ```
19
+ */
20
+ export class RoutePath {
21
+ parts;
22
+ constructor(parts) {
23
+ this.parts = parts;
24
+ }
25
+ static fromString(path, is404 = false) {
26
+ const ctor = is404 ? Route404Path : RoutePath;
27
+ if (path == '/' || path === '') {
28
+ return new ctor([]);
29
+ }
30
+ path = RoutePath.normalizePath(path);
31
+ return new ctor(path.split('/').map((part) => {
32
+ if (part.startsWith(':')) {
33
+ return new RouteParam(part.substring(1));
34
+ }
35
+ else {
36
+ return part;
37
+ }
38
+ }));
39
+ }
40
+ static fromParts(parts) {
41
+ return new RoutePath(parts);
42
+ }
43
+ static create404(layout = []) {
44
+ return new Route404Path(layout);
45
+ }
46
+ static concatPaths = (base, ...rest) => {
47
+ base = RoutePath.normalizePath(base);
48
+ const normalizedRest = rest.map(p => RoutePath.normalizePath(p));
49
+ const restStr = normalizedRest.join('/');
50
+ if (base === '') {
51
+ return RoutePath.normalizePath(restStr);
52
+ }
53
+ return RoutePath.normalizePath([...base.split("/"), ...restStr.split("/")].join("/"));
54
+ };
55
+ /**
56
+ * Normalize a path by removing leading and trailing slashes.
57
+ * @param path The path to normalize.
58
+ */
59
+ static normalizePath = (path) => {
60
+ if (path.startsWith('/')) {
61
+ path = path.substring(1); // remove leading slash
62
+ }
63
+ if (path.endsWith('/')) {
64
+ path = path.slice(0, -1); // remove trailing slash
65
+ }
66
+ return path;
67
+ };
68
+ matches = (path) => {
69
+ const parts = RoutePath.normalizePath(path).split('/');
70
+ if (parts.length != this.parts.length)
71
+ return false;
72
+ return this.parts.every((part, index) => {
73
+ if (part instanceof RouteParam)
74
+ return true;
75
+ return part == parts[index];
76
+ });
77
+ };
78
+ }
79
+ export class Route404Path extends RoutePath {
80
+ parts;
81
+ constructor(parts) {
82
+ super(parts);
83
+ this.parts = parts;
84
+ }
85
+ }
86
+ export function defaultRouterOptions() {
87
+ return {
88
+ defaultTitle: 'App',
89
+ };
90
+ }
91
+ export const ROUTER_CONTEXT_KEY = 'switchboard::router';
92
+ export const LAYOUT_CONTEXT_KEY = 'switchboard::layout';
93
+ export const ROUTE_NOT_FOUND_KEY = '__switchboard__404';
94
+ export function getLayout() {
95
+ return hasContext(LAYOUT_CONTEXT_KEY)
96
+ ? getContext(LAYOUT_CONTEXT_KEY)
97
+ : undefined;
98
+ }
99
+ export function setLayoutContext(layout) {
100
+ // set the layout in the context
101
+ setContext(LAYOUT_CONTEXT_KEY, layout);
102
+ }
103
+ export function getRouter() {
104
+ return getContext(ROUTER_CONTEXT_KEY);
105
+ }
106
+ export function getRouteParams() {
107
+ const router = getRouter();
108
+ return router.currentRoute.params;
109
+ }
110
+ export function getQueryParams() {
111
+ const router = getRouter();
112
+ return router.currentRoute.queryParams;
113
+ }
114
+ export function setRouterContext(router) {
115
+ // set the router in the context
116
+ setContext(ROUTER_CONTEXT_KEY, router);
117
+ }
118
+ /**
119
+ * Gets the nearest route container, either a layout or the router.
120
+ */
121
+ export function getRouteContainer() {
122
+ return getLayout() ?? getRouter();
123
+ }
124
+ /**
125
+ * Create an attachment that sets the href of an anchor element
126
+ * and switches to the route when clicked.
127
+ * This is useful for creating links that work with the router, and won't cause a refresh when clicked.
128
+ * @param href The href to set on the anchor element.
129
+ *
130
+ * @see Link
131
+ */
132
+ export const href = (href) => {
133
+ const router = getRouter();
134
+ return (element) => {
135
+ const handler = () => {
136
+ router.switchTo(href);
137
+ };
138
+ element.href = href;
139
+ element.addEventListener("click", handler);
140
+ element.addEventListener("keydown", handler);
141
+ return () => {
142
+ element.href = "";
143
+ element.removeEventListener("click", handler);
144
+ element.removeEventListener("keydown", handler);
145
+ };
146
+ };
147
+ };
148
+ export const getAllLayouts = (layout) => {
149
+ const list = [];
150
+ if (!layout)
151
+ return list;
152
+ while (layout) {
153
+ list.push(layout);
154
+ layout = layout.parent;
155
+ }
156
+ return list.reverse();
157
+ };
158
+ export const getAllCanonicalLayouts = (layout) => {
159
+ const list = [];
160
+ if (!layout)
161
+ return list;
162
+ while (layout) {
163
+ list.push(layout);
164
+ layout = layout.canonicalParent;
165
+ }
166
+ return list.reverse();
167
+ };