@inglorious/web 4.4.0 → 4.5.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,345 @@
1
+ /**
2
+ * @typedef {import("../../types/router").RouterType} RouterType
3
+ * @typedef {import("../../types/router").RouterEntity} RouterEntity
4
+ * @typedef {import("../../types/router").Api} Api
5
+ */
6
+
7
+ const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
8
+ const REMOVE_COLON_PREFIX = 1
9
+
10
+ export function createHandlers(routeConfig) {
11
+ /**
12
+ * Guard to ensure global listeners are only attached once.
13
+ * @type {boolean}
14
+ */
15
+ let areListenersInitialized = false
16
+
17
+ return {
18
+ /**
19
+ * Initializes the router entity.
20
+ * Sets up the initial route and event listeners for navigation.
21
+ * @param {RouterEntity} entity - The router entity.
22
+ * @param {void} payload - Unused payload.
23
+ * @param {Api} api - The application API.
24
+ */
25
+ init(entity, payload, api) {
26
+ // Handle initial route
27
+ const { pathname, search } = window.location
28
+ const initialPath = pathname + search
29
+ const route = findRoute(routeConfig, initialPath)
30
+ const entityId = entity.id
31
+
32
+ if (route) {
33
+ api.notify(`#${entityId}:navigate`, {
34
+ to: initialPath,
35
+ params: route.params,
36
+ replace: true,
37
+ })
38
+ }
39
+
40
+ if (areListenersInitialized) return
41
+ areListenersInitialized = true
42
+
43
+ // Listen for browser back/forward
44
+ window.addEventListener("popstate", () =>
45
+ api.notify(`#${entityId}:popstate`, payload),
46
+ )
47
+
48
+ // Intercept link clicks
49
+ document.addEventListener("click", (event) => {
50
+ // Find the closest <a> tag (handles clicks on children)
51
+ const link = event.target.closest("a")
52
+
53
+ if (!link) return
54
+
55
+ // Skip external links
56
+ if (link.target === "_blank") return
57
+ if (link.rel === "external") return
58
+ if (link.hasAttribute("data-external")) return
59
+
60
+ // Skip different origin links
61
+ if (link.origin !== window.location.origin) return
62
+
63
+ // Skip links with download attribute
64
+ if (link.hasAttribute("download")) return
65
+
66
+ // Skip mailto:, tel:, etc.
67
+ if (!["http:", "https:"].includes(link.protocol)) return
68
+
69
+ // Prevent default and use router
70
+ event.preventDefault()
71
+
72
+ const path = link.pathname + link.search + link.hash
73
+ api.notify(`#${entityId}:navigate`, path)
74
+ })
75
+ },
76
+
77
+ create(entity) {
78
+ entity.path ??= "/"
79
+ },
80
+
81
+ /**
82
+ * Handles browser `popstate` events.
83
+ * Attempts to match the current location to a route and updates the router
84
+ * entity state. If the matched route is lazy (a loader function) it will
85
+ * attempt to load the module first.
86
+ * @param {RouterEntity} entity
87
+ * @param {Object} payload
88
+ * @param {Api} api
89
+ */
90
+ async popstate(entity, payload, api) {
91
+ const path = window.location.pathname + window.location.search
92
+ const route = findRoute(routeConfig, path)
93
+ const entityId = entity.id
94
+
95
+ if (route) {
96
+ if (typeof route.entityType === "function") {
97
+ entity.isLoading = true
98
+ entity.error = null
99
+
100
+ try {
101
+ const module = await route.entityType()
102
+ api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
103
+ api.notify(`#${entityId}:popstate`, payload)
104
+ } catch (error) {
105
+ api.notify(`#${entityId}:routeLoadError`, { error, path })
106
+ }
107
+
108
+ return
109
+ }
110
+
111
+ updateRouter(entity, route)
112
+
113
+ api.notify("routeChange", {
114
+ route: entity.route,
115
+ params: entity.params,
116
+ query: entity.query,
117
+ path: entity.path,
118
+ })
119
+ }
120
+ },
121
+
122
+ /**
123
+ * Handles navigation to a new route.
124
+ * @param {RouterEntity} entity - The router entity.
125
+ * @param {string|number|{to: string, params?: object, replace?: boolean, state?: object}} payload - The navigation target or options.
126
+ * @param {Api} api - The application API.
127
+ */
128
+ async navigate(entity, payload, api) {
129
+ const options = ["number", "string"].includes(typeof payload)
130
+ ? { to: payload }
131
+ : payload
132
+ const { to, params, replace, force, state = {} } = options
133
+
134
+ // Numeric navigation (back/forward)
135
+ if (typeof to === "number") {
136
+ history.go(to)
137
+ return
138
+ }
139
+
140
+ // Build final path
141
+ let path = to
142
+
143
+ // If params provided and "to" looks like a pattern, build the path
144
+ if (params && to.includes(":")) {
145
+ path = buildPath(to, params)
146
+ }
147
+
148
+ // If "to" is already a final path (like "/users/1"), use it directly
149
+ // The router will match it against patterns in routeConfig
150
+
151
+ const route = findRoute(routeConfig, path)
152
+ const entityId = entity.id
153
+
154
+ if (!route) {
155
+ console.warn(`No route matches path: ${path}`)
156
+ return
157
+ }
158
+
159
+ if (!force) {
160
+ // Prevent navigation if the full path (including query/hash) is identical.
161
+ const currentFullPath =
162
+ entity.path + window.location.search + window.location.hash
163
+ if (path === currentFullPath) {
164
+ return
165
+ }
166
+ }
167
+
168
+ // Asynchronous navigation
169
+ if (typeof route.entityType === "function") {
170
+ entity.isLoading = true
171
+ entity.error = null
172
+
173
+ try {
174
+ const module = await route.entityType()
175
+ api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
176
+ api.notify(`#${entityId}:navigate`, payload)
177
+ } catch (error) {
178
+ api.notify(`#${entityId}:routeLoadError`, { error, path })
179
+ }
180
+
181
+ return
182
+ }
183
+
184
+ updateRouter(entity, route)
185
+
186
+ // Prepare history state
187
+ const historyState = {
188
+ ...state,
189
+ route: entity.route,
190
+ params: entity.params,
191
+ query: entity.query,
192
+ path: entity.path,
193
+ }
194
+
195
+ // Navigate
196
+ const method = replace ? "replaceState" : "pushState"
197
+ history[method](historyState, "", path)
198
+
199
+ api.notify("routeChange", historyState)
200
+ },
201
+
202
+ /**
203
+ * Handles successful loading of a lazily-loaded route module.
204
+ * Registers the loaded type in the runtime type registry via `api.setType`
205
+ * and updates the `routeConfig` entry for the pattern.
206
+ * @param {RouterEntity} entity
207
+ * @param {{module: object, route: {pattern: string, entityType: string}}} payload
208
+ * @param {Api} api
209
+ */
210
+ routeLoadSuccess(entity, payload, api) {
211
+ const { module, route } = payload
212
+
213
+ const [typeName, type] = Object.entries(module).find(
214
+ ([, type]) => type?.render,
215
+ )
216
+
217
+ api.setType(typeName, type)
218
+ routeConfig[route.pattern] = typeName
219
+
220
+ entity.isLoading = false
221
+ },
222
+
223
+ /**
224
+ * Handles errors during lazy route loading.
225
+ * @param {RouterEntity} entity - The router entity.
226
+ * @param {{error: Error, path: string}} payload - The error payload.
227
+ */
228
+ routeLoadError(entity, payload) {
229
+ const { error, path } = payload
230
+ console.error(`Failed to load route ${path}:`, error)
231
+ entity.path = path
232
+ entity.isLoading = false
233
+ entity.error = error
234
+ },
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Builds a URL path by substituting parameters into a route pattern.
240
+ * @param {string} pattern - The route pattern (e.g., "/users/:userId").
241
+ * @param {Record<string, string>} [params={}] - The parameters to substitute.
242
+ * @returns {string} The constructed path.
243
+ * @example
244
+ * buildPath("/users/:userId", { userId: "123" }) // returns "/users/123"
245
+ */
246
+ function buildPath(pattern, params = {}) {
247
+ let path = pattern
248
+ Object.entries(params).forEach(([key, value]) => {
249
+ path = path.replace(`:${key}`, value)
250
+ })
251
+ return path
252
+ }
253
+
254
+ /**
255
+ * Finds a matching route configuration for a given URL path.
256
+ * It supports parameterized routes and a fallback "*" route.
257
+ * @param {Record<string, string>} routeConfig - The routes configuration map.
258
+ * @param {string} pathname - The URL path to match.
259
+ * @returns {{pattern: string, entityType: string, params: Record<string, string>, path: string}|null}
260
+ * The matched route object or null if no match is found.
261
+ */
262
+ function findRoute(routeConfig, pathname) {
263
+ const [path, search] = pathname.split("?")
264
+ let fallbackRoute = null
265
+
266
+ for (const [pattern, entityType] of Object.entries(routeConfig)) {
267
+ if (pattern === "*") {
268
+ fallbackRoute = { pattern, entityType, params: {}, path }
269
+ continue
270
+ }
271
+
272
+ const params = matchRoute(pattern, path)
273
+ if (params !== null) {
274
+ const query = search
275
+ ? Object.fromEntries(new URLSearchParams(search))
276
+ : {}
277
+ return { pattern, entityType, params, path, query }
278
+ }
279
+ }
280
+
281
+ return fallbackRoute
282
+ }
283
+
284
+ /**
285
+ * Matches a URL path against a route pattern and extracts any parameters.
286
+ * @param {string} pattern - The route pattern (e.g., "/users/:userId").
287
+ * @param {string} path - The URL path to match (e.g., "/users/123").
288
+ * @returns {Record<string, string>|null} An object of extracted parameters,
289
+ * or null if the path does not match the pattern.
290
+ */
291
+ function matchRoute(pattern, path) {
292
+ const paramNames = getParamNames(pattern)
293
+ const regex = patternToRegex(pattern)
294
+ const match = path.match(regex)
295
+
296
+ if (!match) return null
297
+
298
+ const params = {}
299
+ paramNames.forEach((name, i) => {
300
+ params[name] = match[i + SKIP_FULL_MATCH_GROUP]
301
+ })
302
+
303
+ return params
304
+ }
305
+
306
+ /**
307
+ * Parses a route pattern and extracts the names of its parameters.
308
+ * @param {string} pattern - The route pattern.
309
+ * @returns {string[]} An array of parameter names.
310
+ * @example
311
+ * getParamNames("/users/:userId/posts/:postId") // returns ["userId", "postId"]
312
+ */
313
+ function getParamNames(pattern) {
314
+ const matches = pattern.match(/:(\w+)/g)
315
+ return matches ? matches.map((m) => m.slice(REMOVE_COLON_PREFIX)) : []
316
+ }
317
+
318
+ /**
319
+ * Converts a route pattern into a regular expression for matching URL paths.
320
+ * @param {string} pattern - The route pattern (e.g., "/users/:userId").
321
+ * @returns {RegExp} The corresponding regular expression.
322
+ */
323
+ function patternToRegex(pattern) {
324
+ const regexPattern = pattern
325
+ .replace(/\//g, "\\/")
326
+ .replace(/:(\w+)/g, "([^\\/]+)")
327
+ return new RegExp(`^${regexPattern}$`)
328
+ }
329
+
330
+ /**
331
+ * Updates the router entity's internal state.
332
+ * @param {RouterEntity} entity - The router entity.
333
+ * @param {object} options - The update options.
334
+ * @param {string} options.entityType - The matched entity type.
335
+ * @param {string} options.path - The full path (pathname only, no query).
336
+ * @param {object} options.params - The extracted route parameters.
337
+ * @param {object} [options.query] - The parsed query parameters.
338
+ */
339
+ function updateRouter(entity, { entityType, path, params, query }) {
340
+ entity.route = entityType
341
+ entity.path = path
342
+ entity.params = params
343
+ entity.query = query
344
+ entity.hash = window.location.hash
345
+ }
@@ -0,0 +1,46 @@
1
+ export function createHelpers(routeConfig) {
2
+ return {
3
+ /**
4
+ * Retrieves the current route configuration.
5
+ * @returns {Record<string, string|function>} The current route configuration.
6
+ */
7
+ getRoutes() {
8
+ return routeConfig
9
+ },
10
+
11
+ /**
12
+ * Retrieves a single route configuration given its path.
13
+ * @param {string} path - The path of the route to retrieve.
14
+ * @returns {string|function|undefined} The route configuration or undefined if not found.
15
+ */
16
+ getRoute(path) {
17
+ return routeConfig[path]
18
+ },
19
+
20
+ /**
21
+ * Sets or updates routes in the route configuration.
22
+ * Can be used both during initialization and at any point to add or update routes dynamically.
23
+ * @param {Record<string, string|function>} routes - An object mapping route paths/patterns to entity type names or loader functions.
24
+ */
25
+ setRoutes(routes) {
26
+ Object.assign(routeConfig, routes)
27
+ },
28
+
29
+ /**
30
+ * Adds a single route to the route configuration.
31
+ * @param {string} path - The route path or pattern (e.g., "/users/:userId").
32
+ * @param {string|function} route - The entity type name or a function that dynamically loads it.
33
+ */
34
+ addRoute(path, route) {
35
+ routeConfig[path] = route
36
+ },
37
+
38
+ /**
39
+ * Removes a route from the route configuration.
40
+ * @param {string} path - The route path or pattern to remove.
41
+ */
42
+ removeRoute(path) {
43
+ delete routeConfig[path]
44
+ },
45
+ }
46
+ }