@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.
Files changed (27) hide show
  1. package/README.md +178 -39
  2. package/fesm2022/loozo-angular-router-_core.mjs +33 -2
  3. package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
  4. package/fesm2022/loozo-angular-router-_generated.mjs +196 -43
  5. package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
  6. package/fesm2022/loozo-angular-router.mjs +2 -2
  7. package/node/src/builders/serve.js +82 -87
  8. package/node/src/generation/emit.js +29 -3
  9. package/node/src/generation/files.d.ts +1 -0
  10. package/node/src/generation/files.js +13 -2
  11. package/node/src/generation/overlay.js +9 -2
  12. package/node/src/generation/sources.js +12 -5
  13. package/package.json +1 -1
  14. package/snippets/generated/destinations.generated.ts +25 -1
  15. package/snippets/src/routes/products/$productId/route.generated.ts +4 -2
  16. package/snippets/src/routes/reports/$reportId/route.generated.ts +4 -2
  17. package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +2 -1
  18. package/snippets/src/routes/teams/$teamId/items/$id/links.ts +6 -4
  19. package/snippets/src/routes/teams/$teamId/items/$id/page.spec.ts +29 -0
  20. package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +4 -2
  21. package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +4 -2
  22. package/snippets/src/routes/teams/$teamId/route.generated.ts +4 -2
  23. package/snippets/src/routes/teams/$teamId/route.ts +3 -0
  24. package/snippets/src/shell.ts +39 -3
  25. package/types/loozo-angular-router-_core.d.ts +135 -53
  26. package/types/loozo-angular-router-_generated.d.ts +111 -61
  27. 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:** experimental and not yet broadly battle-tested. MIT license.
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
- The package is tested against Angular 22.2.2. The `@angular/build` range is narrow on purpose: the application builder's options schema is Angular's own application schema at the version the package was built against. An option added in a later Angular minor is rejected until the package is rebuilt against that minor.
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 or invalid |
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 `withFallback`, 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.
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 }, local resolver results only
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 `data` 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), all three change together. When a navigation is cancelled or fails, they keep their values; 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.
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
- `data` holds this route's own resolver results only, not static `data`, parent data or the title.
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 same arguments:
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(".", { params: { teamId: 7, id: 8 } }); // same as above
369
- route.link("/teams/$teamId/items/$id", { params: { teamId: 7, id: 9 } }); // absolute
370
- route.link("..", { params: { teamId: 7 } }); // parent route
371
- void route.navigate(report, { params: { reportId: 42 } }); // another route's destination
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
- The first argument, when given, is a string literal selecting a route by its full authored path, never a concrete URL. Absolute selectors start with `/`. `"."` selects this route, and `".."` its 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.
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
- 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. There is no extras argument; use `RouterLink` inputs such as `replaceUrl` or `state` for those.
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 the fallback). 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`.
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.7/examples/angular-router/ssr-proof/README.md) is a working SSR app with a search fallback, a resolver, a redirect and hydration.
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; use `"."` or an absolute selector.
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.7/packages/angular-router/SPEC.md): the full contract, including every edge case
616
- - [`examples/angular-router`](https://github.com/theo-matzavinos/loozo/tree/angular-router@0.0.7/examples/angular-router): a minimal app using the builders
617
- - [`examples/angular-router/ssr-proof`](https://github.com/theo-matzavinos/loozo/blob/angular-router@0.0.7/examples/angular-router/ssr-proof/README.md): SSR and hydration
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
- query.push(`${encode(key)}=${stringify(metadata.search[key], value, false)}`);
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