@wavelengthusaf/client-router 0.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 (169) hide show
  1. package/.gitlab-ci.yml +84 -0
  2. package/.husky/pre-commit +38 -0
  3. package/.storybook/main.ts +16 -0
  4. package/.storybook/manager.ts +7 -0
  5. package/.storybook/preview.ts +23 -0
  6. package/.storybook/theme.ts +30 -0
  7. package/.storybook/vitest.setup.ts +7 -0
  8. package/README.md +556 -0
  9. package/dist/elements/RouteElement.d.ts +0 -0
  10. package/dist/elements/RouteElement.d.ts.map +0 -0
  11. package/dist/elements/RouteElement.js +0 -0
  12. package/dist/elements/RouteElement.js.map +0 -0
  13. package/dist/elements/RouterElement.d.ts +0 -0
  14. package/dist/elements/RouterElement.d.ts.map +0 -0
  15. package/dist/elements/RouterElement.js +0 -0
  16. package/dist/elements/RouterElement.js.map +0 -0
  17. package/dist/elements/__tests__/RouteElement.test.d.ts +0 -0
  18. package/dist/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  19. package/dist/elements/__tests__/RouteElement.test.js +77 -0
  20. package/dist/elements/__tests__/RouteElement.test.js.map +0 -0
  21. package/dist/elements/__tests__/RouterElement.test.d.ts +0 -0
  22. package/dist/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  23. package/dist/elements/__tests__/RouterElement.test.js +0 -0
  24. package/dist/elements/__tests__/RouterElement.test.js.map +0 -0
  25. package/dist/index.d.ts +0 -0
  26. package/dist/index.d.ts.map +0 -0
  27. package/dist/index.js +0 -0
  28. package/dist/index.js.map +0 -0
  29. package/dist/models/CompiledRoute.d.ts +0 -0
  30. package/dist/models/CompiledRoute.d.ts.map +0 -0
  31. package/dist/models/CompiledRoute.js +0 -0
  32. package/dist/models/CompiledRoute.js.map +0 -0
  33. package/dist/models/MatchedRoute.d.ts +0 -0
  34. package/dist/models/MatchedRoute.d.ts.map +0 -0
  35. package/dist/models/MatchedRoute.js +0 -0
  36. package/dist/models/MatchedRoute.js.map +0 -0
  37. package/dist/models/RouteComponent.d.ts +0 -0
  38. package/dist/models/RouteComponent.d.ts.map +0 -0
  39. package/dist/models/RouteComponent.js +0 -0
  40. package/dist/models/RouteComponent.js.map +0 -0
  41. package/dist/models/RouteContext.d.ts +0 -0
  42. package/dist/models/RouteContext.d.ts.map +0 -0
  43. package/dist/models/RouteContext.js +0 -0
  44. package/dist/models/RouteContext.js.map +0 -0
  45. package/dist/models/RouteDefinition.d.ts +0 -0
  46. package/dist/models/RouteDefinition.d.ts.map +0 -0
  47. package/dist/models/RouteDefinition.js +0 -0
  48. package/dist/models/RouteDefinition.js.map +0 -0
  49. package/dist/models/RouteGuard.d.ts +0 -0
  50. package/dist/models/RouteGuard.d.ts.map +0 -0
  51. package/dist/models/RouteGuard.js +0 -0
  52. package/dist/models/RouteGuard.js.map +0 -0
  53. package/dist/models/RouteParams.d.ts +0 -0
  54. package/dist/models/RouteParams.d.ts.map +0 -0
  55. package/dist/models/RouteParams.js +0 -0
  56. package/dist/models/RouteParams.js.map +0 -0
  57. package/dist/models/index.d.ts +0 -0
  58. package/dist/models/index.d.ts.map +0 -0
  59. package/dist/models/index.js +0 -0
  60. package/dist/models/index.js.map +0 -0
  61. package/dist/prettier.config.d.ts +0 -0
  62. package/dist/prettier.config.d.ts.map +0 -0
  63. package/dist/router/Router.d.ts +0 -0
  64. package/dist/router/Router.d.ts.map +0 -0
  65. package/dist/router/Router.js +0 -0
  66. package/dist/router/Router.js.map +0 -0
  67. package/dist/router/__tests__/Router.test.d.ts +0 -0
  68. package/dist/router/__tests__/Router.test.d.ts.map +0 -0
  69. package/dist/router/__tests__/Router.test.js +0 -0
  70. package/dist/router/__tests__/Router.test.js.map +0 -0
  71. package/dist/src/elements/RouteElement.d.ts +0 -0
  72. package/dist/src/elements/RouteElement.d.ts.map +0 -0
  73. package/dist/src/elements/RouterElement.d.ts +0 -0
  74. package/dist/src/elements/RouterElement.d.ts.map +0 -0
  75. package/dist/src/elements/__tests__/RouteElement.test.d.ts +0 -0
  76. package/dist/src/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  77. package/dist/src/elements/__tests__/RouterElement.test.d.ts +0 -0
  78. package/dist/src/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  79. package/dist/src/index.d.ts +0 -0
  80. package/dist/src/index.d.ts.map +0 -0
  81. package/dist/src/models/CompiledRoute.d.ts +0 -0
  82. package/dist/src/models/CompiledRoute.d.ts.map +0 -0
  83. package/dist/src/models/MatchedRoute.d.ts +0 -0
  84. package/dist/src/models/MatchedRoute.d.ts.map +0 -0
  85. package/dist/src/models/RouteComponent.d.ts +0 -0
  86. package/dist/src/models/RouteComponent.d.ts.map +0 -0
  87. package/dist/src/models/RouteContext.d.ts +0 -0
  88. package/dist/src/models/RouteContext.d.ts.map +0 -0
  89. package/dist/src/models/RouteDefinition.d.ts +0 -0
  90. package/dist/src/models/RouteDefinition.d.ts.map +0 -0
  91. package/dist/src/models/RouteGuard.d.ts +0 -0
  92. package/dist/src/models/RouteGuard.d.ts.map +0 -0
  93. package/dist/src/models/RouteParams.d.ts +0 -0
  94. package/dist/src/models/RouteParams.d.ts.map +0 -0
  95. package/dist/src/models/index.d.ts +0 -0
  96. package/dist/src/models/index.d.ts.map +0 -0
  97. package/dist/src/router/Router.d.ts +0 -0
  98. package/dist/src/router/Router.d.ts.map +0 -0
  99. package/dist/src/router/__tests__/Router.test.d.ts +0 -0
  100. package/dist/src/router/__tests__/Router.test.d.ts.map +0 -0
  101. package/dist/src/stories/RouterElement.stories.d.ts +0 -0
  102. package/dist/src/stories/RouterElement.stories.d.ts.map +0 -0
  103. package/dist/src/stories/RouterElement.stories.test.d.ts +0 -0
  104. package/dist/src/stories/RouterElement.stories.test.d.ts.map +0 -0
  105. package/dist/src/utils/__tests__/compileRoute.test.d.ts +0 -0
  106. package/dist/src/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  107. package/dist/src/utils/__tests__/matchRoute.test.d.ts +0 -0
  108. package/dist/src/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  109. package/dist/src/utils/compileRoute.d.ts +0 -0
  110. package/dist/src/utils/compileRoute.d.ts.map +0 -0
  111. package/dist/src/utils/index.d.ts +0 -0
  112. package/dist/src/utils/index.d.ts.map +0 -0
  113. package/dist/src/utils/matchRoute.d.ts +0 -0
  114. package/dist/src/utils/matchRoute.d.ts.map +0 -0
  115. package/dist/stories/RouterElement.stories.d.ts +0 -0
  116. package/dist/stories/RouterElement.stories.d.ts.map +0 -0
  117. package/dist/stories/RouterElement.stories.js +0 -0
  118. package/dist/stories/RouterElement.stories.js.map +0 -0
  119. package/dist/utils/__tests__/compileRoute.test.d.ts +0 -0
  120. package/dist/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  121. package/dist/utils/__tests__/compileRoute.test.js +0 -0
  122. package/dist/utils/__tests__/compileRoute.test.js.map +0 -0
  123. package/dist/utils/__tests__/matchRoute.test.d.ts +0 -0
  124. package/dist/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  125. package/dist/utils/__tests__/matchRoute.test.js +0 -0
  126. package/dist/utils/__tests__/matchRoute.test.js.map +0 -0
  127. package/dist/utils/compileRoute.d.ts +0 -0
  128. package/dist/utils/compileRoute.d.ts.map +0 -0
  129. package/dist/utils/compileRoute.js +0 -0
  130. package/dist/utils/compileRoute.js.map +0 -0
  131. package/dist/utils/index.d.ts +0 -0
  132. package/dist/utils/index.d.ts.map +0 -0
  133. package/dist/utils/index.js +0 -0
  134. package/dist/utils/index.js.map +0 -0
  135. package/dist/utils/matchRoute.d.ts +0 -0
  136. package/dist/utils/matchRoute.d.ts.map +0 -0
  137. package/dist/utils/matchRoute.js +0 -0
  138. package/dist/utils/matchRoute.js.map +0 -0
  139. package/eslint.config.mjs +89 -0
  140. package/index.ts +15 -0
  141. package/package.json +54 -0
  142. package/prettier.config.ts +30 -0
  143. package/src/elements/RouteElement.ts +67 -0
  144. package/src/elements/RouterElement.ts +97 -0
  145. package/src/elements/__tests__/RouteElement.test.ts +89 -0
  146. package/src/elements/__tests__/RouterElement.test.ts +253 -0
  147. package/src/index.ts +15 -0
  148. package/src/models/CompiledRoute.ts +7 -0
  149. package/src/models/MatchedRoute.ts +7 -0
  150. package/src/models/RouteComponent.ts +6 -0
  151. package/src/models/RouteContext.ts +7 -0
  152. package/src/models/RouteDefinition.ts +6 -0
  153. package/src/models/RouteGuard.ts +6 -0
  154. package/src/models/RouteParams.ts +3 -0
  155. package/src/models/index.ts +17 -0
  156. package/src/router/Router.ts +225 -0
  157. package/src/router/__tests__/Router.test.ts +464 -0
  158. package/src/stories/RouterElement.stories.test.ts +310 -0
  159. package/src/stories/RouterElement.stories.ts +208 -0
  160. package/src/utils/__tests__/compileRoute.test.ts +97 -0
  161. package/src/utils/__tests__/matchRoute.test.ts +180 -0
  162. package/src/utils/compileRoute.ts +37 -0
  163. package/src/utils/index.ts +4 -0
  164. package/src/utils/matchRoute.ts +37 -0
  165. package/tsconfig.build.json +20 -0
  166. package/tsconfig.json +55 -0
  167. package/vitest.config.ts +34 -0
  168. package/vitest.setup.ts +3 -0
  169. package/vitest.shims.d.ts +1 -0
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { RouteElement } from '../RouteElement.ts';
3
+
4
+ /* eslint-disable @typescript-eslint/no-explicit-any */
5
+ describe('RouteElement', () => {
6
+ beforeEach(() => {
7
+ if (!customElements.get(RouteElement.tag)) {
8
+ customElements.define(RouteElement.tag, RouteElement);
9
+ }
10
+ document.body.innerHTML = '';
11
+ });
12
+
13
+ describe('Attribute Accessors & toDefinition Logic', () => {
14
+ it.each([
15
+ {
16
+ it: 'returns default path "*" if attribute is missing',
17
+ html: `<wavelength-route component="my-view"></wavelength-route>`,
18
+ expectedPath: '*',
19
+ expectedTag: 'my-view'
20
+ },
21
+ {
22
+ it: 'extracts specified path and component tag',
23
+ html: `<wavelength-route path="/home" component="home-view"></wavelength-route>`,
24
+ expectedPath: '/home',
25
+ expectedTag: 'home-view'
26
+ },
27
+ {
28
+ it: 'returns null and warns if component attribute is missing',
29
+ html: `<wavelength-route path="/error"></wavelength-route>`,
30
+ expectedPath: '/error',
31
+ expectedTag: '',
32
+ shouldBeNull: true
33
+ }
34
+ ])('$it', ({ html, expectedPath, expectedTag, shouldBeNull }) => {
35
+ document.body.innerHTML = html;
36
+ const el = document.querySelector<RouteElement>('wavelength-route')!;
37
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
38
+
39
+ expect(el.path).toBe(expectedPath);
40
+ expect(el.componentTag).toBe(expectedTag);
41
+
42
+ const def = el.toDefinition({ document });
43
+
44
+ if (shouldBeNull) {
45
+ expect(def).toBeNull();
46
+ expect(warnSpy).toHaveBeenCalled();
47
+ } else {
48
+ expect(def?.path).toBe(expectedPath);
49
+ expect(def?.routeComponent).toBeDefined();
50
+ }
51
+
52
+ warnSpy.mockRestore();
53
+ });
54
+ });
55
+
56
+ describe('SSR / Environment Resilience', () => {
57
+ it('returns a no-op component if document is missing', () => {
58
+ const el = document.createElement('wavelength-route') as RouteElement;
59
+ el.setAttribute('component', 'test-view');
60
+
61
+ const def = el.toDefinition({});
62
+
63
+ expect(def?.path).toBe('*');
64
+ expect(def?.routeComponent.mount).toBeInstanceOf(Function);
65
+ expect(() => def?.routeComponent.mount({} as any, {} as any)).not.toThrow();
66
+ expect(() => def?.routeComponent.unmount?.()).not.toThrow();
67
+ });
68
+ });
69
+
70
+ describe('RouteComponent Mounting Logic', () => {
71
+ it('properly creates element and injects context on mount', () => {
72
+ const el = document.createElement('wavelength-route') as RouteElement;
73
+ el.setAttribute('component', 'user-profile');
74
+
75
+ const def = el.toDefinition({ document });
76
+ const container = document.createElement('div');
77
+ const mockContext = { path: '/user/1', routeParams: { id: '1' }, query: new URLSearchParams() };
78
+
79
+ def?.routeComponent.mount(container, mockContext as any);
80
+
81
+ const renderedElement = container.querySelector('user-profile') as any;
82
+
83
+ expect(renderedElement).toBeDefined();
84
+ expect(renderedElement.routeContext).toEqual(mockContext);
85
+ expect(def?.routeComponent.unmount).toBeDefined();
86
+ def?.routeComponent.unmount?.();
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { RouterElement } from '../RouterElement.ts';
3
+ import type { RouteElement } from '../RouteElement.ts';
4
+
5
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-member-accessibility */
6
+ const routerInstances: any[] = [];
7
+ const routerArgs: any[] = [];
8
+
9
+ vi.mock('../../router/Router.ts', () => {
10
+ return {
11
+ Router: class {
12
+ start = vi.fn();
13
+ destroy = vi.fn();
14
+ navigate = vi.fn();
15
+ replace = vi.fn();
16
+ back = vi.fn();
17
+
18
+ constructor(...args: any[]) {
19
+ routerInstances.push(this);
20
+ routerArgs.push(args);
21
+ }
22
+ },
23
+ };
24
+ });
25
+
26
+ const createFakeRoute = (def: any | null): RouteElement => {
27
+ const el = document.createElement('wavelength-route') as unknown as RouteElement;
28
+ el.toDefinition = vi.fn().mockReturnValue(def);
29
+ return el;
30
+ };
31
+
32
+ const setWindowProp = (key: string, value: any) => {
33
+ (window as any)[key] = value;
34
+ return () => delete (window as any)[key];
35
+ };
36
+
37
+ describe('RouterElement', () => {
38
+ let container: HTMLDivElement;
39
+ let warnSpy: ReturnType<typeof vi.spyOn>;
40
+
41
+ beforeEach(() => {
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+
45
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
46
+
47
+ routerInstances.length = 0;
48
+ routerArgs.length = 0;
49
+
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ afterEach(() => {
54
+ document.body.innerHTML = '';
55
+ warnSpy.mockRestore();
56
+ });
57
+
58
+ describe('Lifecycle & Bootstrapping', () => {
59
+ it('warns and aborts if no route children are found', () => {
60
+ const el = document.createElement(RouterElement.tag) as RouterElement;
61
+
62
+ container.appendChild(el);
63
+
64
+ expect(warnSpy).toHaveBeenCalledWith(
65
+ '[wavelength-router] No <wavelength-route> children found.',
66
+ );
67
+ expect(routerInstances).toHaveLength(0);
68
+ });
69
+
70
+ it('filters out invalid route definitions', () => {
71
+ const el = document.createElement(RouterElement.tag) as RouterElement;
72
+
73
+ el.appendChild(createFakeRoute({ path: '/valid', routeComponent: {} }));
74
+ el.appendChild(createFakeRoute(null));
75
+
76
+ container.appendChild(el);
77
+
78
+ expect(routerInstances).toHaveLength(1);
79
+
80
+ const [routes] = routerArgs[0];
81
+ expect(routes).toHaveLength(1);
82
+ expect(routes[0]!.path).toBe('/valid');
83
+ });
84
+
85
+ it('starts the router after bootstrap', () => {
86
+ const el = document.createElement(RouterElement.tag) as RouterElement;
87
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
88
+
89
+ container.appendChild(el);
90
+
91
+ expect(routerInstances[0].start).toHaveBeenCalled();
92
+ });
93
+
94
+ it('tears down the router on disconnectedCallback', () => {
95
+ const el = document.createElement(RouterElement.tag) as RouterElement;
96
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
97
+
98
+ container.appendChild(el);
99
+
100
+ const instance = routerInstances[0];
101
+
102
+ container.removeChild(el);
103
+
104
+ expect(instance.destroy).toHaveBeenCalled();
105
+ });
106
+
107
+ it('destroys existing router if routes become empty on re-bootstrap', () => {
108
+ const el = document.createElement(RouterElement.tag) as RouterElement;
109
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
110
+
111
+ container.appendChild(el);
112
+
113
+ const first = routerInstances[0];
114
+
115
+ el.innerHTML = '';
116
+ el.setAttribute('hash-mode', '');
117
+
118
+ expect(first.destroy).toHaveBeenCalled();
119
+ expect(routerInstances).toHaveLength(1);
120
+ });
121
+ });
122
+
123
+ describe('Attribute Reactions & Re-bootstrapping', () => {
124
+ it('ignores attribute changes if not connected to the DOM', () => {
125
+ const el = document.createElement(RouterElement.tag) as RouterElement;
126
+
127
+ el.attributeChangedCallback('hash-mode', null, '');
128
+
129
+ expect(routerInstances).toHaveLength(0);
130
+ });
131
+
132
+ it('re-bootstraps when observed attributes change', () => {
133
+ const el = document.createElement(RouterElement.tag) as RouterElement;
134
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
135
+
136
+ container.appendChild(el);
137
+
138
+ const first = routerInstances[0];
139
+
140
+ el.setAttribute('hash-mode', '');
141
+
142
+ expect(first.destroy).toHaveBeenCalled();
143
+ expect(routerInstances.length).toBeGreaterThan(1);
144
+
145
+ const [, options] = routerArgs[1];
146
+ expect(options.hashMode).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe('Guard Resolution Table', () => {
151
+ it.each([
152
+ {
153
+ it: 'returns undefined if guard attribute is missing',
154
+ setup: () => {},
155
+ guardAttr: null,
156
+ expectedWarn: false,
157
+ expectGuard: false,
158
+ },
159
+ {
160
+ it: 'warns if guard is not found on window',
161
+ setup: () => {},
162
+ guardAttr: 'missingGuard',
163
+ expectedWarn: true,
164
+ expectGuard: false,
165
+ },
166
+ {
167
+ it: 'warns if guard is not a function',
168
+ setup: () => setWindowProp('notAFunc', 'string'),
169
+ guardAttr: 'notAFunc',
170
+ expectedWarn: true,
171
+ expectGuard: false,
172
+ },
173
+ {
174
+ it: 'resolves and passes valid guard',
175
+ setup: () => setWindowProp('validGuard', vi.fn()),
176
+ guardAttr: 'validGuard',
177
+ expectedWarn: false,
178
+ expectGuard: true,
179
+ },
180
+ ])('$it', ({ setup, guardAttr, expectedWarn, expectGuard }) => {
181
+ const cleanup = setup?.();
182
+
183
+ const el = document.createElement(RouterElement.tag) as RouterElement;
184
+
185
+ if (guardAttr) el.setAttribute('guard', guardAttr);
186
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
187
+
188
+ container.appendChild(el);
189
+
190
+ if (expectedWarn) {
191
+ expect(warnSpy).toHaveBeenCalledWith(
192
+ expect.stringContaining('[wavelength-router] Guard'),
193
+ );
194
+ } else {
195
+ expect(warnSpy).not.toHaveBeenCalled();
196
+ }
197
+
198
+ const [, options] = routerArgs[0];
199
+
200
+ if (expectGuard) {
201
+ expect(options.beforeEach).toBeDefined();
202
+ } else {
203
+ expect(options.beforeEach).toBeUndefined();
204
+ }
205
+
206
+ // @ts-expect-error (passing wrong parameters)
207
+ cleanup?.();
208
+ });
209
+ });
210
+
211
+ describe('Delegated Router Methods Table', () => {
212
+ it.each([
213
+ { method: 'navigate', args: ['/path'] },
214
+ { method: 'replace', args: ['/path'] },
215
+ { method: 'back', args: [] },
216
+ ] as const)(
217
+ 'delegates $method to router safely',
218
+ ({ method, args }) => {
219
+ const el = document.createElement(RouterElement.tag) as RouterElement;
220
+
221
+ expect(() => (el[method] as any)(...args)).not.toThrow();
222
+
223
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
224
+ container.appendChild(el);
225
+
226
+ const instance = routerInstances[0];
227
+
228
+ (el[method] as any)(...args);
229
+
230
+ expect(instance[method]).toHaveBeenCalledWith(...args);
231
+ },
232
+ );
233
+ });
234
+
235
+ describe('Router constructor contract', () => {
236
+ it('passes correct options and environment', () => {
237
+ const el = document.createElement(RouterElement.tag) as RouterElement;
238
+
239
+ el.setAttribute('hash-mode', '');
240
+ el.appendChild(createFakeRoute({ path: '/', routeComponent: {} }));
241
+
242
+ container.appendChild(el);
243
+
244
+ const [, options, env] = routerArgs[0];
245
+
246
+ expect(options.outlet).toBe(el);
247
+ expect(options.hashMode).toBe(true);
248
+
249
+ expect(env.window).toBe(window);
250
+ expect(env.document).toBe(document);
251
+ });
252
+ });
253
+ });
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { Router } from './router/Router.ts';
2
+ export { RouterElement } from './elements/RouterElement.ts';
3
+ export { RouteElement } from './elements/RouteElement.ts';
4
+
5
+ export type {
6
+ RouteComponent,
7
+ RouteContext,
8
+ RouteDefinition,
9
+ RouteGuard,
10
+ RouteParams,
11
+ CompiledRoute,
12
+ MatchedRoute,
13
+ } from './models/index.ts';
14
+
15
+ export { compileRoute, matchRoute } from './utils/index.ts';
@@ -0,0 +1,7 @@
1
+ import type { RouteDefinition } from './RouteDefinition.js';
2
+
3
+ export interface CompiledRoute {
4
+ routeDefinition: RouteDefinition;
5
+ pattern: RegExp;
6
+ paramNames: string[];
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { RouteDefinition } from './RouteDefinition.ts';
2
+ import type { RouteParams } from './RouteParams.ts';
3
+
4
+ export interface MatchedRoute {
5
+ routeDefinition: RouteDefinition;
6
+ routeParams: RouteParams;
7
+ }
@@ -0,0 +1,6 @@
1
+ import type { RouteContext } from './RouteContext.ts';
2
+
3
+ export interface RouteComponent {
4
+ mount(container: HTMLElement, context: RouteContext): void;
5
+ unmount?(): void;
6
+ }
@@ -0,0 +1,7 @@
1
+ import type { RouteParams } from './RouteParams.ts';
2
+
3
+ export interface RouteContext {
4
+ path: string;
5
+ routeParams: RouteParams;
6
+ query: URLSearchParams;
7
+ }
@@ -0,0 +1,6 @@
1
+ import type { RouteComponent } from './RouteComponent.ts';
2
+
3
+ export interface RouteDefinition {
4
+ path: string;
5
+ routeComponent: RouteComponent;
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { RouteContext } from './RouteContext.ts';
2
+
3
+ export type RouteGuard = (
4
+ to: RouteContext,
5
+ next: (redirect?: string) => void,
6
+ ) => void;
@@ -0,0 +1,3 @@
1
+ export interface RouteParams {
2
+ [key: string]: string;
3
+ }
@@ -0,0 +1,17 @@
1
+ import type { CompiledRoute } from './CompiledRoute.ts';
2
+ import type { MatchedRoute } from './MatchedRoute.ts';
3
+ import type { RouteComponent } from './RouteComponent.ts';
4
+ import type { RouteContext } from './RouteContext.ts';
5
+ import type { RouteDefinition } from './RouteDefinition.ts';
6
+ import type { RouteGuard } from './RouteGuard.ts';
7
+ import type { RouteParams } from './RouteParams.ts';
8
+
9
+ export type {
10
+ CompiledRoute,
11
+ MatchedRoute,
12
+ RouteComponent,
13
+ RouteContext,
14
+ RouteDefinition,
15
+ RouteGuard,
16
+ RouteParams,
17
+ };
@@ -0,0 +1,225 @@
1
+ import type {
2
+ CompiledRoute,
3
+ MatchedRoute,
4
+ RouteComponent,
5
+ RouteContext,
6
+ RouteDefinition,
7
+ RouteGuard,
8
+ } from '../models/index.ts';
9
+ import { compileRoute, matchRoute } from '../utils/index.ts';
10
+
11
+ export interface RouterEnv {
12
+ window?: Window;
13
+ document?: Document;
14
+ }
15
+
16
+ export interface RouterOptions {
17
+ outlet?: HTMLElement;
18
+ hashMode?: boolean;
19
+ beforeEach?: RouteGuard;
20
+ }
21
+
22
+ export class Router {
23
+ readonly #compiledRoutes: CompiledRoute[];
24
+ readonly #hashMode: boolean;
25
+ readonly #outlet: HTMLElement | undefined;
26
+ readonly #beforeEach?: RouteGuard;
27
+ #currentComponent?: RouteComponent;
28
+ readonly #window?: Window;
29
+ readonly #document?: Document;
30
+
31
+ #isStarted: boolean = false;
32
+
33
+ constructor(
34
+ routeDefinitions: RouteDefinition[],
35
+ routerOptions: RouterOptions = {},
36
+ env: RouterEnv = {},
37
+ ) {
38
+ this.#compiledRoutes = routeDefinitions.map(compileRoute);
39
+ this.#hashMode = routerOptions.hashMode ?? false;
40
+
41
+ if (routerOptions.beforeEach !== undefined) {
42
+ this.#beforeEach = routerOptions.beforeEach;
43
+ }
44
+
45
+ if (env.window !== undefined) {
46
+ this.#window = env.window;
47
+ }
48
+
49
+ if (env.document !== undefined) {
50
+ this.#document = env.document;
51
+ }
52
+
53
+ this.#outlet = routerOptions.outlet ?? this.#document?.body ?? undefined;
54
+ }
55
+
56
+ public start(): void {
57
+ if (!this.#window?.addEventListener || !this.#document?.addEventListener) {
58
+ throw new Error('[Router] Invalid environment: missing DOM APIs');
59
+ }
60
+
61
+ if (this.#isStarted) return;
62
+
63
+ this.#bindEvents();
64
+ this.#resolve();
65
+
66
+ this.#isStarted = true;
67
+ }
68
+
69
+ public destroy(): void {
70
+ if (!this.#window || !this.#document) return;
71
+
72
+ this.#window.removeEventListener('popstate', this.#handlePopState);
73
+ this.#document.removeEventListener('click', this.#handleClick);
74
+
75
+ this.#currentComponent?.unmount?.();
76
+ this.#isStarted = false;
77
+ }
78
+
79
+ public navigate(path: string): void {
80
+ if (!this.#window) return;
81
+
82
+ if (this.#hashMode) {
83
+ this.#window.location.hash = path;
84
+ } else {
85
+ this.#window.history.pushState(null, '', path);
86
+ }
87
+
88
+ this.#resolve();
89
+ }
90
+
91
+ public replace(path: string): void {
92
+ if (!this.#window) return;
93
+
94
+ if (this.#hashMode) {
95
+ this.#window.location.replace('#' + path);
96
+ } else {
97
+ this.#window.history.replaceState(null, '', path);
98
+ }
99
+
100
+ this.#resolve();
101
+ }
102
+
103
+ public back(): void {
104
+ this.#window?.history.back();
105
+ }
106
+
107
+ #bindEvents(): void {
108
+ this.#window!.addEventListener('popstate', this.#handlePopState);
109
+ this.#document!.addEventListener('click', this.#handleClick);
110
+ }
111
+
112
+ #handlePopState = (): void => {
113
+ this.#resolve();
114
+ };
115
+
116
+ #handleClick = (event: MouseEvent): void => {
117
+ const target = event.target as Element | null;
118
+ const anchor = target?.closest?.('a') as HTMLAnchorElement | null;
119
+
120
+ if (!anchor) return;
121
+
122
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
123
+ return;
124
+
125
+ if (anchor.target === '_blank') return;
126
+
127
+ const href: string | null = anchor.getAttribute('href');
128
+
129
+ if (!href) return;
130
+
131
+ if (
132
+ href.startsWith('http') ||
133
+ href.startsWith('//') ||
134
+ href.startsWith('mailto:') ||
135
+ href.startsWith('tel:')
136
+ ) {
137
+ return;
138
+ }
139
+
140
+ event.preventDefault();
141
+ this.navigate(href);
142
+ };
143
+
144
+ #currentPath(): string {
145
+ if (!this.#window) return '/';
146
+
147
+ if (this.#hashMode) {
148
+ const hash: string = this.#window.location.hash.slice(1);
149
+ return hash || '/';
150
+ }
151
+
152
+ return this.#window.location.pathname || '/';
153
+ }
154
+
155
+ #currentQuery(): URLSearchParams {
156
+ if (!this.#window) return new URLSearchParams();
157
+
158
+ if (this.#hashMode) {
159
+ const hash = this.#window.location.hash;
160
+ const queryIndex = hash.indexOf('?');
161
+
162
+ if (queryIndex >= 0) {
163
+ return new URLSearchParams(hash.slice(queryIndex));
164
+ }
165
+
166
+ return new URLSearchParams();
167
+ }
168
+
169
+ return new URLSearchParams(this.#window.location.search);
170
+ }
171
+
172
+ #mount(routeComponent: RouteComponent, routeContext: RouteContext): void {
173
+ if (!this.#outlet) return;
174
+
175
+ this.#currentComponent?.unmount?.();
176
+
177
+ this.#outlet.replaceChildren();
178
+
179
+ routeComponent.mount(this.#outlet, routeContext);
180
+
181
+ this.#currentComponent = routeComponent;
182
+ }
183
+
184
+ #resolve(): void {
185
+ const pathname: string = this.#currentPath();
186
+ const query: URLSearchParams = this.#currentQuery();
187
+
188
+ const match: MatchedRoute | null = matchRoute(
189
+ this.#compiledRoutes,
190
+ pathname,
191
+ );
192
+
193
+ if (!match) {
194
+ console.warn(`[Router] No route matched: ${pathname}`);
195
+ return;
196
+ }
197
+
198
+ const context: RouteContext = {
199
+ path: pathname,
200
+ routeParams: match.routeParams,
201
+ query,
202
+ };
203
+
204
+ if (!this.#beforeEach) {
205
+ this.#mount(match.routeDefinition.routeComponent, context);
206
+ return;
207
+ }
208
+
209
+ let redirected: boolean = false;
210
+
211
+ const next = (redirect?: string): void => {
212
+ if (redirected) return;
213
+
214
+ redirected = true;
215
+
216
+ if (redirect && redirect !== context.path) {
217
+ this.navigate(redirect);
218
+ } else {
219
+ this.#mount(match.routeDefinition.routeComponent, context);
220
+ }
221
+ };
222
+
223
+ this.#beforeEach(context, next);
224
+ }
225
+ }