@loozo/angular-router 0.0.7 → 0.0.9
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 +178 -39
- package/fesm2022/loozo-angular-router-_core.mjs +33 -2
- package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
- package/fesm2022/loozo-angular-router-_generated.mjs +196 -43
- package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
- package/fesm2022/loozo-angular-router.mjs +2 -2
- package/node/src/builders/serve.js +82 -87
- package/node/src/generation/emit.js +29 -3
- package/node/src/generation/files.d.ts +1 -0
- package/node/src/generation/files.js +13 -2
- package/node/src/generation/overlay.js +9 -2
- package/node/src/generation/sources.js +12 -5
- package/package.json +1 -1
- package/snippets/generated/destinations.generated.ts +25 -1
- package/snippets/src/routes/products/$productId/route.generated.ts +4 -2
- package/snippets/src/routes/reports/$reportId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +2 -1
- package/snippets/src/routes/teams/$teamId/items/$id/links.ts +6 -4
- package/snippets/src/routes/teams/$teamId/items/$id/page.spec.ts +29 -0
- package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/route.ts +3 -0
- package/snippets/src/shell.ts +39 -3
- package/types/loozo-angular-router-_core.d.ts +135 -53
- package/types/loozo-angular-router-_generated.d.ts +111 -61
- package/types/loozo-angular-router.d.ts +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Angular still does all the routing: matching, activation, guards, resolvers, red
|
|
|
6
6
|
|
|
7
7
|
Use it when you want route params, query params and links to be type-checked end to end in an Angular 22 application, and you're happy to run generation through the provided Angular builders.
|
|
8
8
|
|
|
9
|
-
**Status:**
|
|
9
|
+
**Status:** pre-1.0: breaking changes come only in minor releases (see [Versions](#versions)). MIT license.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -22,7 +22,23 @@ Peer dependencies:
|
|
|
22
22
|
| `@angular/router` | `^22.0.0` |
|
|
23
23
|
| `@angular/build` | `~22.2.0` |
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
## Versions
|
|
26
|
+
|
|
27
|
+
Releases are numbered `0.MINOR.PATCH` until 1.0. [`SPEC.md`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.9/packages/angular-router/SPEC.md) is the contract: the exports and their types, the builder names and options, the generated file names and what they export, and the behavior it specifies.
|
|
28
|
+
|
|
29
|
+
- A patch (0.1.0 → 0.1.1) doesn't break the contract. It fixes bugs, improves the docs or adds something, such as an export, an option or a codec.
|
|
30
|
+
- A minor (0.1.x → 0.2.0) may break it. Every move to a new Angular minor is also a minor release (see [Angular versions](#angular-versions)), so a `^0.1.0` range never picks up a release that needs a different Angular.
|
|
31
|
+
- Generated files aren't part of the contract byte for byte, so any release may change them. After upgrading, run `ng build` once and commit the regenerated files; until then, the CI check (`ng build --configuration ci`) fails on them.
|
|
32
|
+
|
|
33
|
+
Each release's breaking changes, features and fixes are listed in its [GitHub release](https://github.com/theo-matzavinos/loozo/releases?q=angular-router&expanded=true).
|
|
34
|
+
|
|
35
|
+
## Angular versions
|
|
36
|
+
|
|
37
|
+
Each release supports one Angular minor: the latest stable one when it was published, named by the `@angular/build` range. This release supports Angular 22.2 and is tested against 22.2.2. One release can't serve two minors: the application builder's options schema is Angular's own application schema at the version the package was built against, so an option added in a later Angular minor is rejected until the package is rebuilt against that minor.
|
|
38
|
+
|
|
39
|
+
When Angular ships a new minor or major, a release built against it follows within a week. Earlier minors stay on the release that supported them; fixes aren't backported. [`@loozo/angular-launch`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.9/packages/angular-launch/README.md) pins the router exactly and releases in lockstep: each router release is followed by a Launch release that pins it.
|
|
40
|
+
|
|
41
|
+
A weekly canary builds and tests the package against Angular's `latest` and `next` releases, so the release for a new minor is usually ready when Angular ships it.
|
|
26
42
|
|
|
27
43
|
## Setup
|
|
28
44
|
|
|
@@ -216,6 +232,9 @@ import { defineRoute, numberParam } from "@loozo/angular-router";
|
|
|
216
232
|
export default defineRoute("teams/$teamId", {
|
|
217
233
|
params: { teamId: numberParam },
|
|
218
234
|
search: { score: numberParam },
|
|
235
|
+
resolve: {
|
|
236
|
+
team: ({ params }) => ({ id: params.teamId, name: `Team ${params.teamId}` }),
|
|
237
|
+
},
|
|
219
238
|
});
|
|
220
239
|
```
|
|
221
240
|
|
|
@@ -235,13 +254,14 @@ The member directory is inside the team directory, so the team route is its pare
|
|
|
235
254
|
|
|
236
255
|
### Codecs
|
|
237
256
|
|
|
238
|
-
| Codec | Value
|
|
239
|
-
| ---------------------------- |
|
|
240
|
-
| `stringParam` | Any string, including `""`
|
|
241
|
-
| `numberParam` | A finite number (`" 42 "`, `"1e3"` and `"0x10"` parse; blank and `NaN` fail)
|
|
242
|
-
| `booleanParam` | Exactly `true` or `false`
|
|
243
|
-
| `enumParam(values)` | One of the listed strings, typed as their union
|
|
244
|
-
| `withFallback(codec, value)` | Search only: reads `value` when the key is missing, repeated or invalid
|
|
257
|
+
| Codec | Value |
|
|
258
|
+
| ---------------------------- | ----------------------------------------------------------------------------- |
|
|
259
|
+
| `stringParam` | Any string, including `""` |
|
|
260
|
+
| `numberParam` | A finite number (`" 42 "`, `"1e3"` and `"0x10"` parse; blank and `NaN` fail) |
|
|
261
|
+
| `booleanParam` | Exactly `true` or `false` |
|
|
262
|
+
| `enumParam(values)` | One of the listed strings, typed as their union |
|
|
263
|
+
| `withFallback(codec, value)` | Search only: reads `value` when the key is missing, repeated or invalid |
|
|
264
|
+
| `arrayParam(codec)` | Search only: reads every value of a repeated key (`?tag=a&tag=b`) as an array |
|
|
245
265
|
|
|
246
266
|
For a catchall, the codec applies to each segment, so `$segments[]` with `stringParam` produces `readonly string[]`.
|
|
247
267
|
|
|
@@ -251,15 +271,17 @@ Search params never stop a route from matching, and they never fail. Every searc
|
|
|
251
271
|
search: {
|
|
252
272
|
page: withFallback(numberParam, 1),
|
|
253
273
|
sort: enumParam(["asc", "desc"]),
|
|
274
|
+
tag: arrayParam(enumParam(["news", "guides"])),
|
|
254
275
|
}
|
|
255
276
|
```
|
|
256
277
|
|
|
257
|
-
| Search key | Missing | Repeated
|
|
258
|
-
| -------------------- | ------------ |
|
|
259
|
-
| Plain codec (`sort`) | Absent | Absent
|
|
260
|
-
| `withFallback` | The fallback | The fallback
|
|
278
|
+
| Search key | Missing | Repeated | Invalid |
|
|
279
|
+
| -------------------- | ------------ | ------------ | ----------------------------- |
|
|
280
|
+
| Plain codec (`sort`) | Absent | Absent | Absent |
|
|
281
|
+
| `withFallback` | The fallback | The fallback | The fallback |
|
|
282
|
+
| `arrayParam` (`tag`) | `[]` | Every value | `[]`, if any value is invalid |
|
|
261
283
|
|
|
262
|
-
`sort` reads as `"asc" | "desc" | undefined`. A key with a fallback always reads a value (`page` is a `number`, never `undefined`). Links, `createUrl`, navigation and redirects can leave out any search key.
|
|
284
|
+
`sort` reads as `"asc" | "desc" | undefined`. A key with a fallback always reads a value (`page` is a `number`, never `undefined`). An array key always reads a readonly array: `?tag=news&tag=guides` reads `["news", "guides"]`, `?tag=news` reads `["news"]`, and `?tag=news&tag=other` reads `[]`. Values keep their URL order, duplicates included. Links, `createUrl`, navigation and redirects can leave out any search key. An array key takes an array and writes one `tag=` pair per element; `[]` writes nothing, so an empty list and a missing key are the same URL. For the same reason an array key can't have a fallback: to default to some tags, check `tag.length` in the page.
|
|
263
285
|
|
|
264
286
|
A custom codec implements `ParamCodec<Value>`. Both methods are synchronous: `parse` receives the already-decoded text and returns the value, and `stringify` returns the text:
|
|
265
287
|
|
|
@@ -286,7 +308,7 @@ Parsing should be pure and deterministic. A path codec runs twice per navigation
|
|
|
286
308
|
|
|
287
309
|
`defineRoute` never calls parsers, guards, resolvers or other callbacks; it only records them.
|
|
288
310
|
|
|
289
|
-
The package root exports exactly `defineRoute`, `createUrl`, `stringParam`, `numberParam`, `booleanParam`, `enumParam` and `
|
|
311
|
+
The package root exports exactly `defineRoute`, `createUrl`, `stringParam`, `numberParam`, `booleanParam`, `enumParam`, `withFallback` and `arrayParam`, plus the types `Route` and `ParamCodec`. The `_core`, `_generated` and `_generation` subpaths are internal coupling for generated code and `@loozo/angular-launch`, not application APIs.
|
|
290
312
|
|
|
291
313
|
### Fragments
|
|
292
314
|
|
|
@@ -309,7 +331,7 @@ createUrl(product, {
|
|
|
309
331
|
}); // "/products/42?tab=reviews#summary"
|
|
310
332
|
```
|
|
311
333
|
|
|
312
|
-
All path params are required, including inherited ones. Search params are all optional: an omitted or `undefined` value leaves the key out. Query keys are sorted, values are percent-encoded, and a `/` inside a param value becomes `%2F`. Catchall values are arrays:
|
|
334
|
+
All path params are required, including inherited ones. Search params are all optional: an omitted or `undefined` value leaves the key out, and an `arrayParam` key repeats once per element (`tag: ["a", "b"]` writes `tag=a&tag=b`). Query keys are sorted, values are percent-encoded, and a `/` inside a param value becomes `%2F`. Catchall values are arrays:
|
|
313
335
|
|
|
314
336
|
`snippets/src/docs-url.ts`
|
|
315
337
|
|
|
@@ -340,19 +362,22 @@ export function readInputs() {
|
|
|
340
362
|
|
|
341
363
|
route.params(); // { teamId: number; id: number }
|
|
342
364
|
route.search(); // { score?: number }
|
|
343
|
-
route.data(); // { item: Item },
|
|
365
|
+
route.data(); // { team: { id: number; name: string }; item: Item }, ancestors' resolver results too
|
|
366
|
+
route.title(); // string: this route's title, or the nearest ancestor's
|
|
344
367
|
}
|
|
345
368
|
```
|
|
346
369
|
|
|
347
|
-
`params`, `search` and `
|
|
370
|
+
`params`, `search`, `data` and `title` are read-only Angular signals holding the activated route's values. They have a value from the first read, even in the page's constructor, so there's no loading state to check. While a navigation is in progress they keep the values of the page that's still on screen. When a navigation reuses this route with new values (a new id, or new search params), they change together. When a navigation is cancelled or fails before activation (in matching, a guard or a resolver), they keep their values. When a routed component's constructor throws during activation, the pages above it that stay on screen already have the new values, and keep them until the next navigation completes. Handle the error where Angular reports it: the rejected `navigate` promise, the `NavigationError` event or `withNavigationErrorHandler`. To show that a navigation is pending, read Angular's `Router.currentNavigation()` signal.
|
|
371
|
+
|
|
372
|
+
`data` holds the resolver results of this route and its ancestors, pathless and page-less ones included: the outermost ancestor's first, then nearer ones, then this route's own. As in Angular's own data inheritance, a nearer route's key hides an ancestor's key of the same name, and the hidden value isn't reachable through the handle. The merge is the same whatever `paramsInheritanceStrategy` you set, so under `'emptyOnly'` a route below a parent with a page still has that parent's resolver results, which Angular's own `snapshot.data` leaves out. Static `data` and the title aren't included; to read a static value through the handle, return it from a resolver, such as `resolve: { label: () => "Teams" }`.
|
|
348
373
|
|
|
349
|
-
`
|
|
374
|
+
`title` is the resolved title of this route, or of its nearest ancestor that has one, and changes with the other signals. It's typed `string` when this route or an ancestor declares `title`, and `undefined` otherwise. Like resolver data, it only changes when Angular reruns the title: with the default `runGuardsAndResolvers`, a navigation that changes only search params keeps the old title, so a title that reads search needs `'paramsOrQueryParamsChange'`. Only generated routes count; a hand-written Angular route above them with its own title isn't read.
|
|
350
375
|
|
|
351
376
|
Reading `params` or `search` throws only if a path codec rejects a value it accepted while matching, which a pure codec never does.
|
|
352
377
|
|
|
353
378
|
### Links and navigation
|
|
354
379
|
|
|
355
|
-
`link` returns an Angular `UrlTree` for `RouterLink`; `navigate` calls `Router.navigateByUrl` and returns Angular's own promise. Both take the
|
|
380
|
+
`link` returns an Angular `UrlTree` for `RouterLink`; `navigate` calls `Router.navigateByUrl` and returns Angular's own promise. Both take one object: `to` picks the route, and `params`, `search` and `fragment` are its URL values:
|
|
356
381
|
|
|
357
382
|
`snippets/src/routes/teams/$teamId/items/$id/links.ts`
|
|
358
383
|
|
|
@@ -365,19 +390,89 @@ export function itemLinks() {
|
|
|
365
390
|
const route = injectRoute();
|
|
366
391
|
|
|
367
392
|
route.link({ params: { teamId: 7, id: 8 } }); // this route
|
|
368
|
-
route.link("
|
|
369
|
-
route.link("
|
|
370
|
-
route.
|
|
371
|
-
|
|
393
|
+
route.link({ to: "/teams/$teamId/items/$id", params: { teamId: 7, id: 9 } }); // absolute
|
|
394
|
+
route.link({ to: "..", params: { teamId: 7 } }); // parent route
|
|
395
|
+
void route.navigate({ to: report, params: { reportId: 42 } }); // another route's destination
|
|
396
|
+
|
|
397
|
+
// Angular's navigation behavior options go in the same object, for navigate only.
|
|
398
|
+
void route.navigate({ params: { teamId: 7, id: 8 }, search: { score: 10 }, replaceUrl: true });
|
|
372
399
|
}
|
|
373
400
|
```
|
|
374
401
|
|
|
375
|
-
|
|
402
|
+
Without `to`, the call targets this route. Otherwise `to` is a string literal selecting a route by its full authored path, never a concrete URL, or another route's `destination`. Absolute selectors start with `/`, and `".."` selects the parent route: the nearest route above it with a shorter path, or the root route for a top-level route. There are no other relative selectors. TypeScript only offers selectors that resolve to a known route, and the values are always typed for the selected route.
|
|
403
|
+
|
|
404
|
+
Values are never carried over from the current URL. Pass every required param and search value explicitly, and pass `fragment` only when the target declares it.
|
|
376
405
|
|
|
377
|
-
|
|
406
|
+
The rest of a `navigate` object is Angular's `NavigationBehaviorOptions`, such as `replaceUrl`, `state`, `info` or `skipLocationChange`, passed to `navigateByUrl` as given. The last call above uses `replaceUrl` to replace the current history entry instead of adding one, as a filter input that updates the search on every keystroke would. `link` takes no behavior options; set them as `RouterLink` inputs instead, such as `[replaceUrl]` or `[state]`.
|
|
378
407
|
|
|
379
408
|
A `destination` must come from the same generated route set as the handle. Destinations are for `link` and `navigate` only; `createUrl` takes declarations.
|
|
380
409
|
|
|
410
|
+
### The active route
|
|
411
|
+
|
|
412
|
+
Code outside a route's page, such as a shell, breadcrumbs or a nav bar, reads the active route with `injectActiveRoute()` from `destinations.generated.ts`:
|
|
413
|
+
|
|
414
|
+
`snippets/src/shell.ts`
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { Component, computed } from "@angular/core";
|
|
418
|
+
import { RouterOutlet } from "@angular/router";
|
|
419
|
+
import { injectActiveRoute } from "../generated/destinations.generated.js";
|
|
420
|
+
import { destination as team } from "./routes/teams/$teamId/route.generated.js";
|
|
421
|
+
import { destination as item } from "./routes/teams/$teamId/items/$id/route.generated.js";
|
|
422
|
+
|
|
423
|
+
@Component({
|
|
424
|
+
selector: "app-shell",
|
|
425
|
+
imports: [RouterOutlet],
|
|
426
|
+
template: `
|
|
427
|
+
@if (teamId() !== undefined) {
|
|
428
|
+
<nav>Team {{ teamId() }} <button (click)="next()">Next item</button></nav>
|
|
429
|
+
}
|
|
430
|
+
<router-outlet />
|
|
431
|
+
`,
|
|
432
|
+
})
|
|
433
|
+
export default class Shell {
|
|
434
|
+
// The deepest active route, or undefined before the first navigation.
|
|
435
|
+
readonly active = injectActiveRoute();
|
|
436
|
+
|
|
437
|
+
// An ancestor's params: number | undefined.
|
|
438
|
+
readonly teamId = computed(
|
|
439
|
+
() =>
|
|
440
|
+
this.active()
|
|
441
|
+
?.chain.find((route) => route.is(team))
|
|
442
|
+
?.params().teamId,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Before narrowing, params, search and data read as a union over every route.
|
|
446
|
+
readonly params = computed(() => this.active()?.params());
|
|
447
|
+
|
|
448
|
+
// Whether this route or one of its ancestors is the team route.
|
|
449
|
+
readonly inTeam = computed(() => this.active()?.contains(team) ?? false);
|
|
450
|
+
|
|
451
|
+
next() {
|
|
452
|
+
const route = this.active();
|
|
453
|
+
|
|
454
|
+
// is() narrows to the item's handle, so link and navigate are typed for it.
|
|
455
|
+
if (route?.is(item)) {
|
|
456
|
+
const { teamId, id } = route.params();
|
|
457
|
+
|
|
458
|
+
void route.navigate({ params: { teamId, id: id + 1 }, replaceUrl: true });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
`injectActiveRoute()` needs an injection context and returns a signal of the deepest active route. It's `undefined` before the first navigation, and when no generated route is active. A route with children is the deepest at its own URL: on `/teams/7` it's the team route.
|
|
465
|
+
|
|
466
|
+
Until you narrow it, the active route has `params`, `search`, `data` and `title`, typed as the union of every route's values, and:
|
|
467
|
+
|
|
468
|
+
- `is(destination)`: whether it's exactly that route, not one of its ancestors. It narrows the route to that route's handle, with its `link` and `navigate`.
|
|
469
|
+
- `contains(destination)`: whether that route is this one or one of its ancestors.
|
|
470
|
+
- `chain`: the active routes, from the top-level one down to this one. `chain.find((route) => route.is(team))` finds an ancestor, already narrowed. For breadcrumbs, `chain.map((route) => route.title())` reads each route's title; a route without its own title repeats its ancestor's.
|
|
471
|
+
|
|
472
|
+
The value changes only when a navigation completes. It stays the same while a navigation is pending, and when one is cancelled or fails. If it fails during activation, the routes above the failing one that stay active already have the new values, so `chain` can mix a parent's new params with the active route's old ones until the next navigation completes. While a route stays active it keeps its handle, so going from item 9 to item 10 changes that handle's `params`, not the active route. Each `injectActiveRoute()` call has its own handles, separate from the page's `injectRoute()` handle; their values are the same. A handle that's no longer active keeps its last values. Routes are told apart by their declaration file's path relative to the tsconfig, so two generated route sets with declarations at the same paths can't share one router.
|
|
473
|
+
|
|
474
|
+
Use it outside the page. In the constructor of a page being activated it reads `undefined` until the navigation ends, while the page's own `injectRoute()` has its values right away. On a server-rendered page it's `undefined` from hydration until the browser's first navigation completes, so the shell shows what it shows for `undefined` in between.
|
|
475
|
+
|
|
381
476
|
## Guards, resolvers and redirects
|
|
382
477
|
|
|
383
478
|
`canActivate`, `canActivateChild` and `canMatch` take arrays of functions whose first argument is the parsed inputs. Angular's native arguments follow:
|
|
@@ -400,7 +495,7 @@ export default defineRoute("reports/$reportId", {
|
|
|
400
495
|
| `canMatch` | This route's own path params plus all search params; no ancestor path params |
|
|
401
496
|
| `canDeactivate` | Angular's native form, unchanged; no parsing |
|
|
402
497
|
|
|
403
|
-
Guards return anything Angular accepts (`boolean`, `UrlTree`, `RedirectCommand`, or a Promise or Observable of those). Search values recover before any guard runs (absent, or
|
|
498
|
+
Guards return anything Angular accepts (`boolean`, `UrlTree`, `RedirectCommand`, or a Promise or Observable of those). Search values recover before any guard runs (absent, the fallback, or `[]` for an array key). Path params that fail to parse decide whether a route matches, so a guard only sees valid ones. Every callback (guards, resolvers, `title` and `onInvalidParams`) is called synchronously in the injection context Angular provides, so `inject()` works at its start, before any `await`.
|
|
404
499
|
|
|
405
500
|
Every route with path params of its own or with search params gets a generated `canMatch` that parses them, search after the path. Search never decides the match. If a path param fails, the route doesn't match and Angular tries its next route, ending at the closest `$segments[]` catchall, so guards, resolvers and pages never see a value their codec rejects. Your own `canMatch` guards aren't called then. `onInvalidParams` replaces that outcome:
|
|
406
501
|
|
|
@@ -465,7 +560,7 @@ export default defineRoute("old-items/$id", {
|
|
|
465
560
|
});
|
|
466
561
|
```
|
|
467
562
|
|
|
468
|
-
Params with the same name and compatible types are forwarded automatically; otherwise map them with `params: { targetKey: "sourceKey" }`. Values are parsed with the source codecs and written with the target's codecs. A redirect route can't have a page, children, `canActivate`, `canMatch` or `onInvalidParams`; an invalid param in a redirect's source URL fails the navigation.
|
|
563
|
+
Params with the same name and compatible types are forwarded automatically; otherwise map them with `params: { targetKey: "sourceKey" }`. Values are parsed with the source codecs and written with the target's codecs. Search `preserve` keeps the incoming query text, repeated keys included, so the target's array keys read every value; `replace` writes an array key's values like `createUrl` does. A redirect route can't have a page, children, `canActivate`, `canMatch` or `onInvalidParams`; an invalid param in a redirect's source URL fails the navigation.
|
|
469
564
|
|
|
470
565
|
## Filesystem route definitions
|
|
471
566
|
|
|
@@ -529,7 +624,7 @@ Declarations look the same, but the builder never creates or edits them, and dir
|
|
|
529
624
|
|
|
530
625
|
The generated routes work with Angular SSR and `provideClientHydration` without extra setup; there's no transfer cache or custom renderer. Parsing is synchronous and resolvers keep Angular's stability pending, so server-rendered HTML contains parsed values and resolved data.
|
|
531
626
|
|
|
532
|
-
[`examples/angular-router/ssr-proof`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.
|
|
627
|
+
[`examples/angular-router/ssr-proof`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.9/examples/angular-router/ssr-proof/README.md) is a working SSR app with a search fallback, a resolver, a redirect and hydration.
|
|
533
628
|
|
|
534
629
|
## Unit tests and i18n extraction
|
|
535
630
|
|
|
@@ -552,15 +647,59 @@ Neither builder runs generation; both use the generated files already on disk. A
|
|
|
552
647
|
|
|
553
648
|
The rest is the usual Angular setup. Give specs their own `tsconfig.spec.json` with `"types": ["vitest/globals"]`, and exclude `src/**/*.spec.ts` from the application tsconfig. For `i18n` attributes, add `@angular/localize/init` to the build target's `polyfills`.
|
|
554
649
|
|
|
650
|
+
### Testing pages
|
|
651
|
+
|
|
652
|
+
`injectRoute()` throws unless the component's `ActivatedRoute` is the route it was generated for, so `TestBed.createComponent` on a page fails with `Generated route wiring identity mismatch`. Navigate to the page through the generated routes instead, with Angular's `RouterTestingHarness`:
|
|
653
|
+
|
|
654
|
+
`snippets/src/routes/teams/$teamId/items/$id/page.spec.ts`
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
import { TestBed } from "@angular/core/testing";
|
|
658
|
+
import { provideRouter } from "@angular/router";
|
|
659
|
+
import { RouterTestingHarness } from "@angular/router/testing";
|
|
660
|
+
import { describe, expect, it } from "vitest";
|
|
661
|
+
import { routes } from "../../../../../../generated/routes.generated.js";
|
|
662
|
+
import { ItemCatalog, type Item, type ItemQuery } from "../../../../../catalog.js";
|
|
663
|
+
import ItemPage from "./page.js";
|
|
664
|
+
|
|
665
|
+
async function stubCatalog(query: ItemQuery): Promise<Item> {
|
|
666
|
+
return { ...query, name: "Stub item" };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
describe("ItemPage", () => {
|
|
670
|
+
it("renders the item from the route's resolvers", async () => {
|
|
671
|
+
TestBed.configureTestingModule({
|
|
672
|
+
providers: [provideRouter(routes), { provide: ItemCatalog, useValue: stubCatalog }],
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const harness = await RouterTestingHarness.create();
|
|
676
|
+
const page = await harness.navigateByUrl("/teams/7/items/3?score=5", ItemPage);
|
|
677
|
+
|
|
678
|
+
expect(page.route.params()).toEqual({ teamId: 7, id: 3 });
|
|
679
|
+
expect(page.route.search()).toEqual({ score: 5 });
|
|
680
|
+
// The parent route's resolver result, merged into this route's data.
|
|
681
|
+
expect(page.route.data().team).toEqual({ id: 7, name: "Team 7" });
|
|
682
|
+
expect(page.route.data().item.name).toBe("Stub item");
|
|
683
|
+
expect(harness.routeNativeElement?.querySelector("h1")?.textContent).toBe("Stub item");
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
The navigation runs the real codecs, guards and resolvers. `navigateByUrl` resolves once the page is on screen, with its instance, so the handle already holds the parsed values. Replace what a resolver injects with a provider, as `ItemCatalog` is here. `data` includes the ancestors' resolver results: `team` comes from the `teams/$teamId` route's resolver. With `vitest/globals`, the import from `vitest` is optional.
|
|
689
|
+
|
|
690
|
+
A component inside the page's template gets the page's `ActivatedRoute`, so the same applies to it: render it through the page's route.
|
|
691
|
+
|
|
692
|
+
A component that calls `injectActiveRoute()`, such as a shell, doesn't need a route of its own: `TestBed.createComponent` works, and the active route is `undefined` until a navigation, such as the harness's, completes.
|
|
693
|
+
|
|
555
694
|
## What gets generated
|
|
556
695
|
|
|
557
|
-
| File | Contents
|
|
558
|
-
| ------------------------------------------ |
|
|
559
|
-
| `<outDirectory>/routes.generated.ts` | `routes: Routes` for `provideRouter`
|
|
560
|
-
| `<outDirectory>/destinations.generated.ts` | Shared typing and navigation data (when any route has a page) |
|
|
561
|
-
| `route.generated.ts` beside each route | `injectRoute()` and `destination`; none for redirect routes
|
|
696
|
+
| File | Contents |
|
|
697
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------- |
|
|
698
|
+
| `<outDirectory>/routes.generated.ts` | `routes: Routes` for `provideRouter` |
|
|
699
|
+
| `<outDirectory>/destinations.generated.ts` | Shared typing and navigation data, and `injectActiveRoute()` (when any route has a page) |
|
|
700
|
+
| `route.generated.ts` beside each route | `injectRoute()` and `destination`; none for redirect routes |
|
|
562
701
|
|
|
563
|
-
Each generated file starts with an ownership marker comment such as `// @loozo/angular-router routegen: owned route.generated.ts`. The builder only replaces or deletes marked files with those exact names. It never overwrites an unmarked file in their place, and it removes stale marked files when their routes disappear. Hand edits to generated files are overwritten.
|
|
702
|
+
Each generated file starts with an ownership marker comment such as `// @loozo/angular-router routegen: owned route.generated.ts`. The builder only replaces or deletes marked files with those exact names. It never overwrites an unmarked file in their place, and it removes stale marked files when their routes disappear. Hand edits to generated files are overwritten. Generated files are written with LF line endings; a copy that Git checked out with CRLF line endings, as it does on Windows by default, counts as up to date.
|
|
564
703
|
|
|
565
704
|
Generation reads your source without importing or running it. It validates everything (declarations, overlapping routes, redirects, file ownership) before writing anything, so a run that fails validation leaves your files as they were. Files are then written one at a time, with no rollback: if writing fails partway, for example on a file that isn't writable, or the run is interrupted, some files may already be updated. The next successful run repairs them. Unchanged files aren't rewritten, so their modification times stay the same.
|
|
566
705
|
|
|
@@ -606,15 +745,15 @@ To go back to native file events, set `"poll": false` in the serve target or one
|
|
|
606
745
|
- Primary outlet only: no named outlets, custom matchers or `loadChildren`.
|
|
607
746
|
- One scan directory per application.
|
|
608
747
|
- No data, URL or transfer caches. Resolver data lives in Angular.
|
|
609
|
-
- Relative navigation from a catchall route isn't supported;
|
|
748
|
+
- Relative navigation from a catchall route isn't supported; omit `to` or use an absolute selector.
|
|
610
749
|
- Angular 22.2.2 drops a resolver result named `__proto__`, so a resolver with that name has no value in `data`.
|
|
611
750
|
- Writes are individual file replacements. A write error or an interrupted run can leave mixed output; the next successful run repairs it.
|
|
612
751
|
|
|
613
752
|
## Further reading
|
|
614
753
|
|
|
615
|
-
- [`SPEC.md`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.
|
|
616
|
-
- [`examples/angular-router`](https://github.com/theo-matzavinos/loozo/tree/angular-router@0.0.
|
|
617
|
-
- [`examples/angular-router/ssr-proof`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.
|
|
754
|
+
- [`SPEC.md`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.9/packages/angular-router/SPEC.md): the full contract, including every edge case
|
|
755
|
+
- [`examples/angular-router`](https://github.com/theo-matzavinos/loozo/tree/angular-router@0.0.9/examples/angular-router): a minimal app using the builders
|
|
756
|
+
- [`examples/angular-router/ssr-proof`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.9/examples/angular-router/ssr-proof/README.md): SSR and hydration
|
|
618
757
|
|
|
619
758
|
## Developing this package
|
|
620
759
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const fallbacks = /* @__PURE__ */ new WeakMap();
|
|
2
|
+
const arrays = /* @__PURE__ */ new WeakSet();
|
|
2
3
|
function codecFallback(codec) {
|
|
3
4
|
return fallbacks.get(codec);
|
|
4
5
|
}
|
|
6
|
+
function isArrayCodec(codec) {
|
|
7
|
+
return arrays.has(codec);
|
|
8
|
+
}
|
|
5
9
|
function validateCodec(codec) {
|
|
6
10
|
if (codec === null || typeof codec !== "object" && typeof codec !== "function" || typeof codec.parse !== "function" || typeof codec.stringify !== "function") throw new TypeError("Codec must have callable parse and stringify methods");
|
|
7
11
|
}
|
|
@@ -58,6 +62,7 @@ function enumParam(values) {
|
|
|
58
62
|
function withFallback(codec, fallback) {
|
|
59
63
|
validateCodec(codec);
|
|
60
64
|
if (codecFallback(codec)) throw new TypeError("withFallback cannot wrap a codec that already has a fallback");
|
|
65
|
+
if (arrays.has(codec)) throw new TypeError("withFallback cannot wrap an arrayParam codec");
|
|
61
66
|
const wrapper = {
|
|
62
67
|
parse(raw) {
|
|
63
68
|
return codec.parse(raw);
|
|
@@ -69,6 +74,21 @@ function withFallback(codec, fallback) {
|
|
|
69
74
|
fallbacks.set(wrapper, Object.freeze({ value: fallback }));
|
|
70
75
|
return wrapper;
|
|
71
76
|
}
|
|
77
|
+
function arrayParam(codec) {
|
|
78
|
+
validateCodec(codec);
|
|
79
|
+
if (codecFallback(codec)) throw new TypeError("arrayParam cannot wrap a codec that has a fallback");
|
|
80
|
+
if (arrays.has(codec)) throw new TypeError("arrayParam cannot wrap another arrayParam codec");
|
|
81
|
+
const wrapper = {
|
|
82
|
+
parse(raw) {
|
|
83
|
+
return codec.parse(raw);
|
|
84
|
+
},
|
|
85
|
+
stringify(value) {
|
|
86
|
+
return codec.stringify(value);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
arrays.add(wrapper);
|
|
90
|
+
return wrapper;
|
|
91
|
+
}
|
|
72
92
|
function parsePath(path) {
|
|
73
93
|
if (typeof path !== "string") throw new TypeError("Path must be a string");
|
|
74
94
|
if (path === "") return Object.freeze([]);
|
|
@@ -152,6 +172,7 @@ function copyCodecs(value, path) {
|
|
|
152
172
|
const codec = record[key];
|
|
153
173
|
validateCodec(codec);
|
|
154
174
|
if (path && codecFallback(codec)) throw new TypeError("withFallback codecs are search-only");
|
|
175
|
+
if (path && isArrayCodec(codec)) throw new TypeError("arrayParam codecs are search-only");
|
|
155
176
|
result[key] = codec;
|
|
156
177
|
}
|
|
157
178
|
return Object.freeze(result);
|
|
@@ -184,6 +205,7 @@ function snapshotRedirect(value, source) {
|
|
|
184
205
|
checkKeys(search, /* @__PURE__ */ new Set(["mode", "values"]), "redirect search");
|
|
185
206
|
const values = ownRecord(search.values, "Redirect replacement");
|
|
186
207
|
checkKeys(values, new Set(Object.keys(target.search)), "search values");
|
|
208
|
+
for (const key of Object.keys(values)) if (isArrayCodec(target.search[key]) && values[key] !== void 0 && !Array.isArray(values[key])) throw new TypeError(`Redirect search array requires an array: ${key}`);
|
|
187
209
|
searchSnapshot = {
|
|
188
210
|
mode: "replace",
|
|
189
211
|
values: Object.freeze({ ...values })
|
|
@@ -350,6 +372,11 @@ function createUrl(route, values) {
|
|
|
350
372
|
if (typeof options.fragment !== "string" || !metadata.fragments.includes(options.fragment)) throw new TypeError("Fragment must belong to this declaration");
|
|
351
373
|
}
|
|
352
374
|
for (const segment of metadata.segments) if (segment.kind === "catchall" && !Array.isArray(params[segment.name])) throw new TypeError(`Catchall requires an array: ${segment.name}`);
|
|
375
|
+
for (const key of searchKeys) {
|
|
376
|
+
if (!isArrayCodec(metadata.search[key]) || !has(search, key)) continue;
|
|
377
|
+
const value = search[key];
|
|
378
|
+
if (value !== void 0 && !Array.isArray(value)) throw new TypeError(`Search array requires an array: ${key}`);
|
|
379
|
+
}
|
|
353
380
|
const parts = [];
|
|
354
381
|
for (const segment of metadata.segments) if (segment.kind === "static") parts.push(encode(segment.text));
|
|
355
382
|
else if (segment.kind === "scalar") parts.push(stringify(metadata.params[segment.name], params[segment.name], true));
|
|
@@ -359,12 +386,16 @@ function createUrl(route, values) {
|
|
|
359
386
|
for (const key of searchKeys) {
|
|
360
387
|
const value = search[key];
|
|
361
388
|
if (!has(search, key) || value === void 0) continue;
|
|
362
|
-
|
|
389
|
+
if (!isArrayCodec(metadata.search[key])) {
|
|
390
|
+
query.push(`${encode(key)}=${stringify(metadata.search[key], value, false)}`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
for (const element of value) query.push(`${encode(key)}=${stringify(metadata.search[key], element, false)}`);
|
|
363
394
|
}
|
|
364
395
|
if (query.length > 0) url = `${url}?${query.join("&")}`;
|
|
365
396
|
if (has(options, "fragment")) url = `${url}#${encode(options.fragment)}`;
|
|
366
397
|
return url;
|
|
367
398
|
}
|
|
368
|
-
export { booleanParam, codecFallback, createUrl, defineRoute, enumParam, numberParam, routeMetadata, stringParam, withFallback };
|
|
399
|
+
export { arrayParam, booleanParam, codecFallback, createUrl, defineRoute, enumParam, isArrayCodec, numberParam, routeMetadata, stringParam, withFallback };
|
|
369
400
|
|
|
370
401
|
//# sourceMappingURL=loozo-angular-router-_core.mjs.map
|