@inglorious/web 3.0.1 → 4.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.
package/README.md CHANGED
@@ -367,12 +367,12 @@ No additional configuration is needed.
367
367
 
368
368
  ### 1. Setup the Router
369
369
 
370
- To enable the router, add it to your store's types and create a `router` entity. The entity's `routes` property maps URL patterns to the entity types that represent your pages.
370
+ To enable the router, add it to your store's types and create a `router` entity. Register route patterns using the router module helpers (`setRoutes`, `addRoute`) routes are configured at module level and not stored on the router entity itself.
371
371
 
372
372
  ```javascript
373
373
  // store.js
374
374
  import { createStore, html } from "@inglorious/web"
375
- import { router } from "@inglorious/web/router"
375
+ import { router, setRoutes } from "@inglorious/web/router"
376
376
 
377
377
  const types = {
378
378
  // 1. Add the router type to your store's types
@@ -395,14 +395,9 @@ const types = {
395
395
  }
396
396
 
397
397
  const entities = {
398
- // 3. Create the router entity
398
+ // 3. Create the router entity (no `routes` here)
399
399
  router: {
400
400
  type: "router",
401
- routes: {
402
- "/": "homePage",
403
- "/users/:id": "userPage",
404
- "*": "notFoundPage", // Fallback for unmatched routes
405
- },
406
401
  },
407
402
  userPage: {
408
403
  type: "userPage",
@@ -411,6 +406,13 @@ const entities = {
411
406
  }
412
407
 
413
408
  export const store = createStore({ types, entities })
409
+
410
+ // Register routes at module level
411
+ setRoutes({
412
+ "/": "homePage",
413
+ "/users/:id": "userPage",
414
+ "*": "notFoundPage",
415
+ })
414
416
  ```
415
417
 
416
418
  ### 2. Render the Current Route
@@ -456,22 +458,23 @@ api.notify("navigate", {
456
458
 
457
459
  ### 4. Lazy Loading Routes
458
460
 
459
- You can improve performance by lazy-loading routes. Instead of a string, provide a function that returns a dynamic import.
461
+ You can improve performance by lazy-loading routes. Use a loader function that returns a dynamic import when registering the route via `setRoutes`.
460
462
 
461
463
  **Note:** The imported module must use a named export for the entity type (not `export default`), so the router can register it with a unique name in the store.
462
464
 
463
465
  ```javascript
464
466
  // store.js
465
467
  const entities = {
466
- router: {
467
- type: "router",
468
- routes: {
469
- "/": "homePage",
470
- // Lazy load: returns a Promise resolving to a module
471
- "/admin": () => import("./pages/admin.js"),
472
- },
473
- },
468
+ router: { type: "router" },
474
469
  }
470
+
471
+ export const store = createStore({ types, entities })
472
+
473
+ setRoutes({
474
+ "/": "homePage",
475
+ // Lazy load: returns a Promise resolving to a module
476
+ "/admin": () => import("./pages/admin.js"),
477
+ })
475
478
  ```
476
479
 
477
480
  ```javascript
@@ -538,22 +541,18 @@ const types = {
538
541
  }
539
542
 
540
543
  const entities = {
541
- router: {
542
- type: "router",
543
- routes: {
544
- "/login": "loginPage",
545
- "/admin": "adminPage",
546
- },
547
- },
548
- adminPage: {
549
- type: "adminPage",
550
- },
551
- loginPage: {
552
- type: "loginPage",
553
- },
544
+ router: { type: "router" },
545
+ adminPage: { type: "adminPage" },
546
+ loginPage: { type: "loginPage" },
554
547
  }
555
548
 
556
549
  export const store = createStore({ types, entities })
550
+
551
+ // Register routes via the router module API
552
+ setRoutes({
553
+ "/login": "loginPage",
554
+ "/admin": "adminPage",
555
+ })
557
556
  ```
558
557
 
559
558
  #### How Type Composition Works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "3.0.1",
3
+ "version": "4.0.1",
4
4
  "description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -53,8 +53,8 @@
53
53
  },
54
54
  "files": [
55
55
  "src",
56
- "!src/**/*.test.js",
57
- "types"
56
+ "types",
57
+ "!src/**/*.test.js"
58
58
  ],
59
59
  "publishConfig": {
60
60
  "access": "public"
@@ -63,8 +63,9 @@
63
63
  "**/*.css"
64
64
  ],
65
65
  "dependencies": {
66
+ "@lit-labs/ssr-client": "^1.1.8",
66
67
  "lit-html": "^3.3.1",
67
- "@inglorious/store": "8.0.1",
68
+ "@inglorious/store": "9.0.0",
68
69
  "@inglorious/utils": "3.7.1"
69
70
  },
70
71
  "devDependencies": {
package/src/mount.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { hydrate } from "@lit-labs/ssr-client"
1
2
  import { html, render } from "lit-html"
2
3
 
3
4
  /**
@@ -11,20 +12,19 @@ export function mount(store, renderFn, element) {
11
12
  const api = { ...store._api }
12
13
  api.render = createRender(api)
13
14
 
14
- // let renderScheduled = false
15
+ let shouldHydrate = element.hasChildNodes()
15
16
 
16
- // const scheduleRender = () => {
17
- // if (!renderScheduled) {
18
- // renderScheduled = true
19
- // requestAnimationFrame(() => {
20
- // renderScheduled = false
21
- // })
22
- // render(renderFn(api), element)
23
- // }
24
- // }
17
+ const unsubscribe = store.subscribe(() => {
18
+ const template = renderFn(api)
19
+
20
+ if (shouldHydrate) {
21
+ hydrate(template, element)
22
+ shouldHydrate = false
23
+ } else {
24
+ render(template, element)
25
+ }
26
+ })
25
27
 
26
- // const unsubscribe = store.subscribe(scheduleRender)
27
- const unsubscribe = store.subscribe(() => render(renderFn(api), element))
28
28
  store.notify("init")
29
29
 
30
30
  return unsubscribe
@@ -7,6 +7,18 @@
7
7
  const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
8
8
  const REMOVE_COLON_PREFIX = 1
9
9
 
10
+ /**
11
+ * Route configuration map. Keys are route patterns (e.g. `/users/:id`) and
12
+ * values are either a string type name or a loader function that returns a
13
+ * module exporting a type.
14
+ * @type {Record<string, string|function>}
15
+ */
16
+ const routeConfig = {}
17
+
18
+ /**
19
+ * Guard to ensure global listeners are only attached once.
20
+ * @type {boolean}
21
+ */
10
22
  let areListenersInitialized = false
11
23
 
12
24
  /**
@@ -25,7 +37,7 @@ export const router = {
25
37
  // Handle initial route
26
38
  const { pathname, search } = window.location
27
39
  const initialPath = pathname + search
28
- const route = findRoute(entity.routes, initialPath)
40
+ const route = findRoute(routeConfig, initialPath)
29
41
  const entityId = entity.id
30
42
 
31
43
  if (route) {
@@ -40,19 +52,9 @@ export const router = {
40
52
  areListenersInitialized = true
41
53
 
42
54
  // Listen for browser back/forward
43
- window.addEventListener("popstate", () => {
44
- const path = window.location.pathname + window.location.search
45
- const { routes } = api.getEntity(entityId)
46
- const route = findRoute(routes, path)
47
-
48
- if (route) {
49
- api.notify(`#${entityId}:routeSync`, {
50
- entityType: route.entityType,
51
- path,
52
- params: route.params,
53
- })
54
- }
55
- })
55
+ window.addEventListener("popstate", () =>
56
+ api.notify(`#${entityId}:popstate`, payload),
57
+ )
56
58
 
57
59
  // Intercept link clicks
58
60
  document.addEventListener("click", (event) => {
@@ -79,20 +81,42 @@ export const router = {
79
81
  event.preventDefault()
80
82
 
81
83
  const path = link.pathname + link.search + link.hash
82
- api.notify("navigate", path)
84
+ api.notify(`#${entityId}:navigate`, path)
83
85
  })
84
86
  },
85
87
 
86
- create(entity) {
87
- entity.routes ??= {}
88
- },
88
+ /**
89
+ * Handles browser `popstate` events.
90
+ * Attempts to match the current location to a route and updates the router
91
+ * entity state. If the matched route is lazy (a loader function) it will
92
+ * attempt to load the module first.
93
+ * @param {RouterEntity} entity
94
+ * @param {Object} payload
95
+ * @param {Api} api
96
+ */
97
+ async popstate(entity, payload, api) {
98
+ const path = window.location.pathname + window.location.search
99
+ const route = findRoute(routeConfig, path)
100
+ const entityId = entity.id
89
101
 
90
- routeAdd(entity, route) {
91
- entity.routes[route.path] = route.entityType
92
- },
102
+ if (route) {
103
+ if (typeof route.entityType === "function") {
104
+ entity.isLoading = true
105
+ entity.error = null
106
+
107
+ try {
108
+ const module = await route.entityType()
109
+ api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
110
+ api.notify(`#${entityId}:popstate`, payload)
111
+ } catch (error) {
112
+ api.notify(`#${entityId}:routeLoadError`, { error, path })
113
+ }
114
+
115
+ return
116
+ }
93
117
 
94
- routeRemove(entity, path) {
95
- delete [entity.routes[path]]
118
+ updateRouter(entity, route)
119
+ }
96
120
  },
97
121
 
98
122
  /**
@@ -122,9 +146,10 @@ export const router = {
122
146
  }
123
147
 
124
148
  // If "to" is already a final path (like "/users/1"), use it directly
125
- // The router will match it against patterns in entity.routes
149
+ // The router will match it against patterns in routeConfig
126
150
 
127
- const route = findRoute(entity.routes, path)
151
+ const route = findRoute(routeConfig, path)
152
+ const entityId = entity.id
128
153
 
129
154
  if (!route) {
130
155
  console.warn(`No route matches path: ${path}`)
@@ -142,64 +167,57 @@ export const router = {
142
167
 
143
168
  // Asynchronous navigation
144
169
  if (typeof route.entityType === "function") {
145
- entity.loading = true
170
+ entity.isLoading = true
146
171
  entity.error = null
147
- const entityId = entity.id
148
172
 
149
173
  try {
150
174
  const module = await route.entityType()
151
- api.notify(`#${entityId}:loadSuccess`, {
152
- module,
153
- route,
154
- path,
155
- replace,
156
- state,
157
- })
175
+ api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
176
+ api.notify(`#${entityId}:navigate`, payload)
158
177
  } catch (error) {
159
- api.notify(`#${entityId}:loadError`, { error, path })
178
+ api.notify(`#${entityId}:routeLoadError`, { error, path })
160
179
  }
161
180
 
162
181
  return
163
182
  }
164
183
 
165
- // Synchronous navigation
184
+ updateRouter(entity, route)
166
185
 
167
- doNavigate(
168
- entity,
169
- {
170
- entityType: route.entityType,
171
- path,
172
- params: route.params,
173
- replace,
174
- state,
175
- },
176
- api,
177
- )
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)
178
200
  },
179
201
 
180
202
  /**
181
- * Handles the successful loading of a lazy route module.
182
- * @param {RouterEntity} entity - The router entity.
183
- * @param {{module: object, route: object, path: string, replace: boolean, state: object}} payload - The success payload.
184
- * @param {Api} api - The application API.
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
185
209
  */
186
- loadSuccess(entity, payload, api) {
187
- const { module, route, path, replace, state } = payload
188
-
189
- const [[typeName, type]] = Object.entries(module)
190
-
191
- api.notify("morph", { name: typeName, type })
210
+ routeLoadSuccess(entity, payload, api) {
211
+ const { module, route } = payload
192
212
 
193
- entity.routes[route.pattern] = typeName
213
+ const [typeName, type] = Object.entries(module).find(
214
+ ([, type]) => type?.render,
215
+ )
194
216
 
195
- // Complete the navigation
196
- entity.loading = false
217
+ api.setType(typeName, type)
218
+ routeConfig[route.pattern] = typeName
197
219
 
198
- doNavigate(
199
- entity,
200
- { entityType: typeName, path, params: route.params, replace, state },
201
- api,
202
- )
220
+ entity.isLoading = false
203
221
  },
204
222
 
205
223
  /**
@@ -207,22 +225,56 @@ export const router = {
207
225
  * @param {RouterEntity} entity - The router entity.
208
226
  * @param {{error: Error, path: string}} payload - The error payload.
209
227
  */
210
- loadError(entity, payload) {
228
+ routeLoadError(entity, payload) {
211
229
  const { error, path } = payload
212
230
  console.error(`Failed to load route ${path}:`, error)
213
231
  entity.path = path
214
- entity.loading = false
232
+ entity.isLoading = false
215
233
  entity.error = error
216
234
  },
235
+ }
217
236
 
218
- /**
219
- * Synchronizes the router state with the browser's history (e.g., on popstate).
220
- * @param {RouterEntity} entity - The router entity.
221
- * @param {{entityType: string, path: string, params: object}} payload - The sync payload.
222
- */
223
- routeSync(entity, payload) {
224
- updateRouter(entity, payload)
225
- },
237
+ /**
238
+ * Retrieves the current route configuration.
239
+ * @returns {Record<string, string|function>} The current route configuration.
240
+ */
241
+ export function getRoutes() {
242
+ return routeConfig
243
+ }
244
+
245
+ /**
246
+ * Retrieves a single route configuration given its path.
247
+ * @param {string} path - The path of the route to retrieve.
248
+ * @returns {string|function|undefined} The route configuration or undefined if not found.
249
+ */
250
+ export function getRoute(path) {
251
+ return routeConfig[path]
252
+ }
253
+
254
+ /**
255
+ * Sets or updates routes in the route configuration.
256
+ * Can be used both during initialization and at any point to add or update routes dynamically.
257
+ * @param {Record<string, string|function>} routes - An object mapping route paths/patterns to entity type names or loader functions.
258
+ */
259
+ export function setRoutes(routes) {
260
+ Object.assign(routeConfig, routes)
261
+ }
262
+
263
+ /**
264
+ * Adds a single route to the route configuration.
265
+ * @param {string} path - The route path or pattern (e.g., "/users/:userId").
266
+ * @param {string|function} route - The entity type name or a function that dynamically loads it.
267
+ */
268
+ export function addRoute(path, route) {
269
+ routeConfig[path] = route
270
+ }
271
+
272
+ /**
273
+ * Removes a route from the route configuration.
274
+ * @param {string} path - The route path or pattern to remove.
275
+ */
276
+ export function removeRoute(path) {
277
+ delete routeConfig[path]
226
278
  }
227
279
 
228
280
  /**
@@ -244,25 +296,30 @@ function buildPath(pattern, params = {}) {
244
296
  /**
245
297
  * Finds a matching route configuration for a given URL path.
246
298
  * It supports parameterized routes and a fallback "*" route.
247
- * @param {Record<string, string>} routes - The routes configuration map.
248
- * @param {string} path - The URL path to match.
299
+ * @param {Record<string, string>} routeConfig - The routes configuration map.
300
+ * @param {string} pathname - The URL path to match.
249
301
  * @returns {{pattern: string, entityType: string, params: Record<string, string>, path: string}|null}
250
302
  * The matched route object or null if no match is found.
251
303
  */
252
- function findRoute(routes, path) {
253
- const [pathname] = path.split("?")
304
+ function findRoute(routeConfig, pathname) {
305
+ const [path, search] = pathname.split("?")
254
306
  let fallbackRoute = null
255
307
 
256
- for (const [pattern, entityType] of Object.entries(routes)) {
308
+ for (const [pattern, entityType] of Object.entries(routeConfig)) {
257
309
  if (pattern === "*") {
258
- fallbackRoute = { pattern, entityType, params: {}, path: pathname }
310
+ fallbackRoute = { pattern, entityType, params: {}, path }
259
311
  continue
260
312
  }
261
- const params = matchRoute(pattern, pathname)
313
+
314
+ const params = matchRoute(pattern, path)
262
315
  if (params !== null) {
263
- return { pattern, entityType, params, path: pathname }
316
+ const query = search
317
+ ? Object.fromEntries(new URLSearchParams(search))
318
+ : {}
319
+ return { pattern, entityType, params, path, query }
264
320
  }
265
321
  }
322
+
266
323
  return fallbackRoute
267
324
  }
268
325
 
@@ -312,50 +369,18 @@ function patternToRegex(pattern) {
312
369
  return new RegExp(`^${regexPattern}$`)
313
370
  }
314
371
 
315
- /**
316
- * Performs the actual navigation by updating entity state and browser history.
317
- * @param {RouterEntity} entity - The router entity.
318
- * @param {object} options - Navigation options.
319
- * @param {string} options.entityType - The type of the entity to render.
320
- * @param {string} options.path - The full path.
321
- * @param {object} options.params - The route parameters.
322
- * @param {boolean} [options.replace] - Whether to replace the current history entry.
323
- * @param {object} [options.state] - Additional state to save in history.
324
- * @param {Api} api - The application API.
325
- */
326
- function doNavigate(entity, { entityType, path, params, replace, state }, api) {
327
- updateRouter(entity, { entityType, path, params })
328
-
329
- // Prepare history state
330
- const historyState = {
331
- ...state,
332
- route: entity.route,
333
- params: entity.params,
334
- query: entity.query,
335
- path: entity.path,
336
- }
337
-
338
- // Navigate
339
- const method = replace ? "replaceState" : "pushState"
340
- history[method](historyState, "", path)
341
-
342
- api.notify("routeChange", historyState)
343
- }
344
-
345
372
  /**
346
373
  * Updates the router entity's internal state.
347
374
  * @param {RouterEntity} entity - The router entity.
348
375
  * @param {object} options - The update options.
349
376
  * @param {string} options.entityType - The matched entity type.
350
- * @param {string} options.path - The full path.
351
- * @param {object} options.params - The extracted parameters.
377
+ * @param {string} options.path - The full path (pathname only, no query).
378
+ * @param {object} options.params - The extracted route parameters.
379
+ * @param {object} [options.query] - The parsed query parameters.
352
380
  */
353
- function updateRouter(entity, { entityType, path, params }) {
354
- const [pathname, search] = path.split("?")
355
- const query = search ? Object.fromEntries(new URLSearchParams(search)) : {}
356
-
357
- entity.path = pathname
381
+ function updateRouter(entity, { entityType, path, params, query }) {
358
382
  entity.route = entityType
383
+ entity.path = path
359
384
  entity.params = params
360
385
  entity.query = query
361
386
  entity.hash = window.location.hash
package/types/router.d.ts CHANGED
@@ -26,8 +26,6 @@ export type QueryParams = Record<string, string>
26
26
  export interface RouterEntity {
27
27
  /** A unique identifier for the router entity. */
28
28
  id: string | number
29
- /** The route configuration. */
30
- routes: RoutesConfig
31
29
  /** The current active path, without query string or hash. */
32
30
  path?: string
33
31
  /** The entity type of the current active route. */
@@ -38,6 +36,10 @@ export interface RouterEntity {
38
36
  query?: QueryParams
39
37
  /** The hash from the current URL. */
40
38
  hash?: string
39
+ /** Whether a route is currently loading asynchronously. */
40
+ isLoading?: boolean
41
+ /** An error that occurred during route loading. */
42
+ error?: Error | null
41
43
  }
42
44
 
43
45
  /**
@@ -98,7 +100,7 @@ export interface RouterType {
98
100
  entity: RouterEntity,
99
101
  payload: string | number | NavigatePayload,
100
102
  api: StoreApi,
101
- ): void
103
+ ): void | Promise<void>
102
104
 
103
105
  /**
104
106
  * Synchronizes the router entity's state with data from a routing event,
@@ -112,19 +114,61 @@ export interface RouterType {
112
114
  payload: RouteSyncPayload,
113
115
  api: StoreApi,
114
116
  ): void
117
+ /**
118
+ * Handles browser `popstate` events. May perform async loading for lazy routes.
119
+ */
120
+ popstate(
121
+ entity: RouterEntity,
122
+ payload: any,
123
+ api: StoreApi,
124
+ ): void | Promise<void>
115
125
 
116
126
  /**
117
- * Handles successful async route loading.
118
- * @param {RouterEntity} entity - The router entity to update.
119
- * @param {Object} payload - The load success payload.
120
- * @param {StoreApi} api - The store API.
127
+ * Handles successful async route loading for a pattern.
128
+ * Registers the loaded type and updates runtime route config.
121
129
  */
122
- loadSuccess(entity: RouterEntity, payload: any, api: StoreApi): void
130
+ routeLoadSuccess(entity: RouterEntity, payload: any, api: StoreApi): void
123
131
 
124
132
  /**
125
- * Handles async route loading errors.
126
- * @param {RouterEntity} entity - The router entity.
127
- * @param {Object} payload - The error payload.
133
+ * Handles errors that occurred while loading a lazy route.
128
134
  */
129
- loadError(entity: RouterEntity, payload: any): void
135
+ routeLoadError(entity: RouterEntity, payload: any): void
130
136
  }
137
+
138
+ /**
139
+ * Returns the current route configuration.
140
+ * @returns {Record<string, string|function>} The current route configuration.
141
+ */
142
+ export function getRoutes(): Record<string, string | (() => Promise<any>)>
143
+
144
+ /**
145
+ * Retrieves a single route configuration given its path.
146
+ * @param {string} path - The path of the route to retrieve.
147
+ * @returns {string|function|undefined} The route configuration or undefined if not found.
148
+ */
149
+ export function getRoute(path: string): string | (() => Promise<any>)
150
+
151
+ /**
152
+ * Sets or updates routes in the route configuration.
153
+ * Can be used both during initialization and at any point to add or update routes dynamically.
154
+ * @param routes An object mapping route paths/patterns to entity type names or loader functions.
155
+ */
156
+ export function setRoutes(
157
+ routes: Record<string, string | (() => Promise<any>)>,
158
+ ): void
159
+
160
+ /**
161
+ * Adds a single route to the route configuration.
162
+ * @param path The route path or pattern (e.g., "/users/:userId").
163
+ * @param route The entity type name or a function that dynamically loads it.
164
+ */
165
+ export function addRoute(
166
+ path: string,
167
+ route: string | (() => Promise<any>),
168
+ ): void
169
+
170
+ /**
171
+ * Removes a route from the route configuration.
172
+ * @param path The route path or pattern to remove.
173
+ */
174
+ export function removeRoute(path: string): void