@loozo/angular-router 0.0.3 → 0.0.4

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 (50) hide show
  1. package/README.md +243 -121
  2. package/fesm2022/loozo-angular-router-_core.mjs +19 -41
  3. package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
  4. package/fesm2022/loozo-angular-router-_generated.mjs +148 -315
  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/application.js +12 -3
  8. package/node/src/builders/options.js +9 -3
  9. package/node/src/builders/serve.js +21 -14
  10. package/node/src/generation/emit.js +26 -8
  11. package/node/src/generation/filesystem.js +261 -69
  12. package/node/src/generation/generate.d.ts +1 -1
  13. package/node/src/generation/generate.js +6 -1
  14. package/node/src/generation/sources.js +7 -2
  15. package/node/src/generation/validate.js +15 -24
  16. package/package.json +1 -1
  17. package/schemas/application.json +29 -20
  18. package/schemas/serve.json +142 -7
  19. package/snippets/angular.json +29 -0
  20. package/snippets/generated/destinations.generated.ts +126 -0
  21. package/snippets/generated/routes.generated.ts +35 -0
  22. package/snippets/src/catalog.ts +20 -0
  23. package/snippets/src/docs-url.ts +8 -0
  24. package/snippets/src/index.html +11 -0
  25. package/snippets/src/main.ts +6 -0
  26. package/snippets/src/product-url.ts +8 -0
  27. package/snippets/src/routes/products/$productId/page.ts +20 -0
  28. package/snippets/src/routes/products/$productId/route.generated.ts +24 -0
  29. package/snippets/src/routes/products/$productId/route.ts +7 -0
  30. package/snippets/src/routes/reports/$reportId/page.ts +9 -0
  31. package/snippets/src/routes/reports/$reportId/route.generated.ts +24 -0
  32. package/snippets/src/routes/reports/$reportId/route.ts +6 -0
  33. package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +15 -0
  34. package/snippets/src/routes/teams/$teamId/items/$id/links.ts +13 -0
  35. package/snippets/src/routes/teams/$teamId/items/$id/page.ts +9 -0
  36. package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +24 -0
  37. package/snippets/src/routes/teams/$teamId/items/$id/route.ts +18 -0
  38. package/snippets/src/routes/teams/$teamId/members/$memberId/page.ts +9 -0
  39. package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +24 -0
  40. package/snippets/src/routes/teams/$teamId/members/$memberId/route.ts +7 -0
  41. package/snippets/src/routes/teams/$teamId/old-items/$id/route.ts +13 -0
  42. package/snippets/src/routes/teams/$teamId/route.generated.ts +24 -0
  43. package/snippets/src/routes/teams/$teamId/route.ts +6 -0
  44. package/snippets/src/shell.ts +9 -0
  45. package/snippets/src/slug.ts +14 -0
  46. package/snippets/tsconfig.app.json +14 -0
  47. package/snippets/tsconfig.routes.json +5 -0
  48. package/types/loozo-angular-router-_core.d.ts +141 -99
  49. package/types/loozo-angular-router-_generated.d.ts +48 -31
  50. package/types/loozo-angular-router.d.ts +2 -2
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @loozo/angular-router
2
2
 
3
- Typed routes for Angular's own router. You declare each route once with `defineRoute`: its path, its parent, and codecs for its path params, search params and fragments. A build step turns those declarations into an ordinary Angular `Routes` array plus a small typed module next to each route. Pages read parsed inputs as Angular Resources and build links and navigations that TypeScript checks against the target route.
3
+ Typed routes for Angular's own router. Each route is a directory holding a `defineRoute` declaration and, usually, a page. The declaration holds the route's path, its parent, and codecs for its path params, search params and fragments; the directories give the path and parent, and a build step keeps them in step. The build step then turns the declarations into an ordinary Angular `Routes` array plus a small typed module next to each route. Pages read parsed inputs as Angular signals and build links and navigations that TypeScript checks against the target route.
4
4
 
5
5
  Angular still does all the routing: matching, activation, guards, resolvers, redirects, history and lazy loading. This package doesn't add a second router; it generates the configuration Angular consumes.
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. Version `0.0.3`, MIT license.
9
+ **Status:** experimental and not yet broadly battle-tested. Version `0.0.4`, MIT license.
10
10
 
11
11
  ## Install
12
12
 
@@ -30,23 +30,37 @@ Generation runs inside two Angular builders that replace your project's `build`
30
30
 
31
31
  In `angular.json`:
32
32
 
33
+ `snippets/angular.json`
34
+
33
35
  ```json
34
- "build": {
35
- "builder": "@loozo/angular-router:application",
36
- "options": {
37
- "tsconfig": "tsconfig.routes.json",
38
- "scanDirectories": ["src/routes"],
39
- "outDirectory": "generated",
40
- "browser": "src/main.ts",
41
- "index": "src/index.html",
42
- "tsConfig": "tsconfig.app.json",
43
- "outputPath": "dist/app"
44
- },
45
- "configurations": { "ci": { "check": true } }
46
- },
47
- "serve": {
48
- "builder": "@loozo/angular-router:serve",
49
- "options": { "buildTarget": "app:build" }
36
+ {
37
+ "version": 1,
38
+ "projects": {
39
+ "app": {
40
+ "projectType": "application",
41
+ "root": "",
42
+ "sourceRoot": "src",
43
+ "architect": {
44
+ "build": {
45
+ "builder": "@loozo/angular-router:application",
46
+ "options": {
47
+ "tsconfig": "tsconfig.routes.json",
48
+ "scanDirectories": ["src/routes"],
49
+ "outDirectory": "generated",
50
+ "browser": "src/main.ts",
51
+ "index": "src/index.html",
52
+ "tsConfig": "tsconfig.app.json",
53
+ "outputPath": "dist/app"
54
+ },
55
+ "configurations": { "ci": { "check": true } }
56
+ },
57
+ "serve": {
58
+ "builder": "@loozo/angular-router:serve",
59
+ "options": { "buildTarget": "app:build" }
60
+ }
61
+ }
62
+ }
63
+ }
50
64
  }
51
65
  ```
52
66
 
@@ -54,6 +68,8 @@ The application builder takes the router options (`tsconfig`, `scanDirectories`,
54
68
 
55
69
  Note that `tsconfig` and `tsConfig` are different options. `tsconfig` is the TypeScript config used to find route declarations; `tsConfig` is Angular's own compilation config. The example app uses a small `tsconfig.routes.json` that extends the app config:
56
70
 
71
+ `snippets/tsconfig.routes.json`
72
+
57
73
  ```json
58
74
  {
59
75
  "extends": "./tsconfig.app.json",
@@ -62,10 +78,31 @@ Note that `tsconfig` and `tsConfig` are different options. `tsconfig` is the Typ
62
78
  }
63
79
  ```
64
80
 
65
- Your app's `tsconfig.app.json` must include the generated files, for example `"include": ["src/**/*.ts", "generated/**/*.ts"]`.
81
+ Your app's `tsconfig.app.json` must include the generated files, as its `"include"` does here:
82
+
83
+ `snippets/tsconfig.app.json`
84
+
85
+ ```json
86
+ {
87
+ "compilerOptions": {
88
+ "target": "ES2022",
89
+ "module": "ESNext",
90
+ "moduleResolution": "Bundler",
91
+ "strict": true,
92
+ "skipLibCheck": true,
93
+ "lib": ["ES2022", "DOM"],
94
+ "types": []
95
+ },
96
+ "angularCompilerOptions": { "strictTemplates": true },
97
+ "files": ["src/main.ts"],
98
+ "include": ["src/**/*.ts", "generated/**/*.ts"]
99
+ }
100
+ ```
66
101
 
67
102
  Then install the generated routes with Angular's `provideRouter`:
68
103
 
104
+ `snippets/src/main.ts`
105
+
69
106
  ```ts
70
107
  import { bootstrapApplication } from "@angular/platform-browser";
71
108
  import { provideRouter } from "@angular/router";
@@ -87,21 +124,23 @@ Commit the generated files along with your route declarations, and run the `ci`
87
124
 
88
125
  ## Quick start
89
126
 
90
- A route is a directory under the scan directory with a declaration file and, optionally, a page. In explicit mode (the default) the file names are up to you: the builder finds declarations by their `export default defineRoute(...)` and pages by their default-exported standalone component, not by name.
127
+ A route is a directory under the scan directory with a page, a `route.ts` declaration, or both. The directories give the path: a page in `src/routes/products/$productId/` is the route `products/$productId`, where `$productId` is a path param. Add the page, and the first `ng build` or `ng serve` creates its declaration with a `stringParam` codec for `productId`. The declaration is yours to edit; here the codec is now `numberParam`, with a search param and fragments added:
128
+
129
+ `snippets/src/routes/products/$productId/route.ts`
91
130
 
92
131
  ```ts
93
- // src/routes/product/definition.ts
94
- import { defineRoute, enumParam, numberParam, optionalSearchParam } from "@loozo/angular-router";
132
+ import { defineRoute, enumParam, numberParam, withFallback } from "@loozo/angular-router";
95
133
 
96
134
  export default defineRoute("products/$productId", {
97
135
  params: { productId: numberParam },
98
- search: { tab: optionalSearchParam(enumParam(["details", "reviews"])) },
136
+ search: { tab: withFallback(enumParam(["details", "reviews"]), "details") },
99
137
  fragments: ["summary", "reviews"],
100
138
  });
101
139
  ```
102
140
 
141
+ `snippets/src/routes/products/$productId/page.ts`
142
+
103
143
  ```ts
104
- // src/routes/product/page.ts
105
144
  import { Component } from "@angular/core";
106
145
  import { RouterLink } from "@angular/router";
107
146
  import { injectRoute } from "./route.generated.js";
@@ -109,14 +148,8 @@ import { injectRoute } from "./route.generated.js";
109
148
  @Component({
110
149
  imports: [RouterLink],
111
150
  template: `
112
- @if (route.params.hasValue() && route.search.hasValue()) {
113
- <h1>Product {{ route.params.value().productId }}</h1>
114
- <p>Tab: {{ route.search.value().tab ?? "details" }}</p>
115
- } @else if (route.params.error()) {
116
- <p>This product link isn't valid.</p>
117
- } @else {
118
- <p>Loading…</p>
119
- }
151
+ <h1>Product {{ route.params().productId }}</h1>
152
+ <p>Tab: {{ route.search().tab }}</p>
120
153
  <a [routerLink]="reviews">Reviews</a>
121
154
  `,
122
155
  })
@@ -130,9 +163,9 @@ export default class Product {
130
163
  }
131
164
  ```
132
165
 
133
- Running `ng build` or `ng serve` writes `generated/routes.generated.ts`, `generated/destinations.generated.ts` and `src/routes/product/route.generated.ts`. The page is lazy-loaded with `loadComponent`.
166
+ Running `ng build` or `ng serve` also writes `generated/routes.generated.ts`, `generated/destinations.generated.ts` and `src/routes/products/$productId/route.generated.ts`. The page is lazy-loaded with `loadComponent`. See [Filesystem route definitions](#filesystem-route-definitions) for how directories map to paths and parents, and [Explicit authoring](#explicit-authoring) to write every path and parent yourself instead.
134
167
 
135
- The template checks `hasValue()` before reading values. A URL such as `/products/abc` still activates this route, because it has no guards or resolvers to reject it first, and then the params fail to parse. Reading `value()` in that error state throws.
168
+ The template reads the signals directly: they hold the activated route's values from the moment the page is created. A URL such as `/products/abc` doesn't match this route at all: a path param that fails to parse makes Angular try its next route, such as a `$segments[]` not-found route. A missing, repeated or unknown `tab` reads as its fallback, `"details"`.
136
169
 
137
170
  ## Declaring routes
138
171
 
@@ -140,7 +173,7 @@ The template checks `hasValue()` before reading values. A URL such as `/products
140
173
  defineRoute(path, config);
141
174
  ```
142
175
 
143
- `path` must be a string literal; TypeScript infers everything else without generic arguments.
176
+ `path` must be a string literal; TypeScript infers everything else without generic arguments. By default the builder writes `path` and `parent` from the directories, and you write the rest.
144
177
 
145
178
  ### Paths
146
179
 
@@ -154,42 +187,51 @@ Paths are decoded segment text, not URLs:
154
187
  | `"docs/$segments[]"` | A terminal catchall: zero or more remaining segments, parsed as an array |
155
188
  | `"$id/compare/$id"` | A repeated param uses the same value in every position |
156
189
 
157
- No leading, trailing or repeated slashes. A catchall must be the last segment, a route can have at most one, and a catchall route can't have children. A root `$segments[]` route works as a not-found fallback; ordinary routes still match before it.
190
+ No leading, trailing or repeated slashes. A catchall must be the last segment, a route can have at most one, and a catchall route can't have children. A root `$segments[]` route works as a not-found fallback; ordinary routes still match before it, unless their path params fail to parse.
158
191
 
159
192
  ### Config
160
193
 
161
- | Key | Purpose |
162
- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
163
- | `parent` | The parent route, imported as the parent declaration's default export |
164
- | `params` | One codec per path param, keyed by bare name. Required exactly when the path has params |
165
- | `search` | Codecs for query params |
166
- | `fragments` | The fragment names this route accepts |
167
- | `resolve` | Resolvers that receive parsed inputs |
168
- | `canActivate`, `canActivateChild`, `canMatch` | Guards that receive parsed inputs |
169
- | `canDeactivate`, `providers`, `title`, `data`, `runGuardsAndResolvers` | Passed to Angular unchanged |
170
- | `redirect` | A typed redirect to another route |
194
+ | Key | Purpose |
195
+ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
196
+ | `parent` | The parent route, imported as the parent declaration's default export |
197
+ | `params` | One codec per path param, keyed by bare name. Required exactly when the path has params |
198
+ | `search` | Codecs for query params |
199
+ | `fragments` | The fragment names this route accepts |
200
+ | `resolve` | Resolvers that receive parsed inputs |
201
+ | `canActivate`, `canActivateChild`, `canMatch` | Guards that receive parsed inputs |
202
+ | `onInvalidParams` | What happens when this route's own path params fail to parse, instead of not matching |
203
+ | `title` | A string, or a callback that receives parsed inputs |
204
+ | `canDeactivate`, `providers`, `data`, `runGuardsAndResolvers` | Passed to Angular unchanged |
205
+ | `redirect` | A typed redirect to another route |
171
206
 
172
207
  Structural keys such as `path`, `children`, `component`, `loadComponent`, `redirectTo`, `pathMatch`, `matcher` and `outlet` are rejected: generation owns them.
173
208
 
174
209
  ### Parents
175
210
 
211
+ `snippets/src/routes/teams/$teamId/route.ts`
212
+
176
213
  ```ts
177
- // src/routes/team/definition.ts
214
+ import { defineRoute, numberParam } from "@loozo/angular-router";
215
+
178
216
  export default defineRoute("teams/$teamId", {
179
217
  params: { teamId: numberParam },
180
- search: { score: optionalSearchParam(numberParam) },
218
+ search: { score: numberParam },
181
219
  });
220
+ ```
182
221
 
183
- // src/routes/item/definition.ts
184
- import team from "../team/definition.js";
222
+ `snippets/src/routes/teams/$teamId/members/$memberId/route.ts`
185
223
 
186
- export default defineRoute("items/$id", {
224
+ ```ts
225
+ import { defineRoute, numberParam } from "@loozo/angular-router";
226
+ import team from "../../route.js";
227
+
228
+ export default defineRoute("members/$memberId", {
187
229
  parent: team,
188
- params: { id: numberParam },
230
+ params: { memberId: numberParam },
189
231
  });
190
232
  ```
191
233
 
192
- A child declares only its own params; it inherits its ancestors' params and search params, so the item route above has `params: { teamId, id }` and `search: { score }`. A child can't redeclare a param or search key its ancestors own. Pathless routes (`""` with a parent) add no URL segment but still contribute their inputs to descendants. A route with neither a page nor children is an error, unless it's a redirect.
234
+ The member directory is inside the team directory, so the team route is its parent. The team directory has no page, so the team route is componentless: it groups its children and their shared inputs. A child declares only its own params; it inherits its ancestors' params and search params, so the member route above has `params: { teamId, memberId }` and `search: { score }`. A child can't redeclare a param or search key its ancestors own. Pathless routes (`""` with a parent) add no URL segment but still contribute their inputs to descendants. A route with neither a page nor children is an error, unless it's a redirect.
193
235
 
194
236
  ### Codecs
195
237
 
@@ -199,12 +241,29 @@ A child declares only its own params; it inherits its ancestors' params and sear
199
241
  | `numberParam` | A finite number (`" 42 "`, `"1e3"` and `"0x10"` parse; blank and `NaN` fail) |
200
242
  | `booleanParam` | Exactly `true` or `false` |
201
243
  | `enumParam(values)` | One of the listed strings, typed as their union |
202
- | `optionalSearchParam(codec)` | Search only: the key may be absent |
203
- | `nullableSearchParam(codec)` | Search only: the literal value `null` maps to `null` |
244
+ | `withFallback(codec, value)` | Search only: reads `value` when the key is missing, repeated or invalid |
245
+
246
+ For a catchall, the codec applies to each segment, so `$segments[]` with `stringParam` produces `readonly string[]`.
247
+
248
+ Search params never stop a route from matching, and they never fail. Every search key is optional:
249
+
250
+ ```ts
251
+ search: {
252
+ page: withFallback(numberParam, 1),
253
+ sort: enumParam(["asc", "desc"]),
254
+ }
255
+ ```
204
256
 
205
- Search params are required unless wrapped in `optionalSearchParam`. `optionalSearchParam(nullableSearchParam(codec))` distinguishes absent, `null` and a value. For a catchall, the codec applies to each segment, so `$segments[]` with `stringParam` produces `readonly string[]`.
257
+ | Search key | Missing | Repeated or invalid |
258
+ | -------------------- | ------------ | ------------------- |
259
+ | Plain codec (`sort`) | Absent | Absent |
260
+ | `withFallback` | The fallback | The fallback |
206
261
 
207
- A custom codec implements `ParamCodec<Value>`. `parse` receives the already-decoded text and may be async; `stringify` must be synchronous:
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.
263
+
264
+ 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
+
266
+ `snippets/src/slug.ts`
208
267
 
209
268
  ```ts
210
269
  import type { ParamCodec } from "@loozo/angular-router";
@@ -223,9 +282,11 @@ export const slug: ParamCodec<string> = {
223
282
  };
224
283
  ```
225
284
 
285
+ Parsing should be pure and deterministic. A path codec runs twice per navigation: once while Angular matches routes and again for the activated route. A codec whose `parse` returns a Promise is a type error where you declare it; if one slips through (from JavaScript, say), the navigation fails with a `TypeError` rather than treating the value as invalid. Codecs don't run in an injection context and don't need one: load data in resolvers or guards instead.
286
+
226
287
  `defineRoute` never calls parsers, guards, resolvers or other callbacks; it only records them.
227
288
 
228
- The package root exports exactly `defineRoute`, `createUrl`, `stringParam`, `numberParam`, `booleanParam`, `enumParam`, `optionalSearchParam` and `nullableSearchParam`, 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.
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.
229
290
 
230
291
  ### Fragments
231
292
 
@@ -235,9 +296,11 @@ The package root exports exactly `defineRoute`, `createUrl`, `stringParam`, `num
235
296
 
236
297
  `createUrl` builds a root-relative URL from a declaration, synchronously and without running any parsers:
237
298
 
299
+ `snippets/src/product-url.ts`
300
+
238
301
  ```ts
239
302
  import { createUrl } from "@loozo/angular-router";
240
- import product from "./routes/product/definition.js";
303
+ import product from "./routes/products/$productId/route.js";
241
304
 
242
305
  createUrl(product, {
243
306
  params: { productId: 42 },
@@ -246,7 +309,9 @@ createUrl(product, {
246
309
  }); // "/products/42?tab=reviews#summary"
247
310
  ```
248
311
 
249
- All path params are required, including inherited ones. Search params follow their codecs (required, optional or nullable). Query keys are sorted, values are percent-encoded, and a `/` inside a param value becomes `%2F`. Catchall values are arrays:
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:
313
+
314
+ `snippets/src/docs-url.ts`
250
315
 
251
316
  ```ts
252
317
  const docs = defineRoute("docs/$segments[]", { params: { segments: stringParam } });
@@ -264,36 +329,47 @@ Each route that has a page or descendants with pages gets a colocated `route.gen
264
329
  - `injectRoute()`: call it in an injection context (a field initializer or constructor) to get the handle for this route.
265
330
  - `destination`: an inert reference to this route that other routes can link to.
266
331
 
332
+ `snippets/src/routes/teams/$teamId/items/$id/inputs.ts`
333
+
267
334
  ```ts
268
335
  import { injectRoute } from "./route.generated.js";
269
336
 
270
- const route = injectRoute();
337
+ // injectRoute() needs an injection context, such as a component field initializer.
338
+ export function readInputs() {
339
+ const route = injectRoute();
271
340
 
272
- route.params; // Resource<{ teamId: number; id: number } | undefined>
273
- route.search; // Resource<{ score?: number } | undefined>
274
- route.data; // Resource<{ item: Item } | undefined>, local resolver results only
341
+ route.params(); // { teamId: number; id: number }
342
+ route.search(); // { score?: number }
343
+ route.data(); // { item: Item }, local resolver results only
344
+ }
275
345
  ```
276
346
 
277
- `params`, `search` and `data` are read-only Angular Resources, so `status()`, `isLoading()`, `hasValue()`, `value()` and `error()` all work. Check `hasValue()` before reading `value()`: reading the value of a Resource in the error state throws. While a navigation is in progress they're loading with `undefined` values rather than showing stale ones.
278
-
279
- `params` and `search` are parsed together and settle together. If a codec fails after the route has activated, both are in the error state, and the error has `source` (`"path"` or `"search"`), `key` and `reason` (`"missing"`, `"repeated"` or `"invalid"`). `data` settles on its own and isn't affected by that failure, so check `params` or `search` for parsing errors, not `data`. A failed navigation is different: when a guard or resolver throws, for example, the routes still on screen report that error in all three Resources instead of stale values.
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.
280
348
 
281
349
  `data` holds this route's own resolver results only, not static `data`, parent data or the title.
282
350
 
283
- Parsing keeps Angular's stability pending until the values are published, so server rendering waits for async codecs.
351
+ Reading `params` or `search` throws only if a path codec rejects a value it accepted while matching, which a pure codec never does.
284
352
 
285
353
  ### Links and navigation
286
354
 
287
355
  `link` returns an Angular `UrlTree` for `RouterLink`; `navigate` calls `Router.navigateByUrl` and returns Angular's own promise. Both take the same arguments:
288
356
 
357
+ `snippets/src/routes/teams/$teamId/items/$id/links.ts`
358
+
289
359
  ```ts
290
- import { destination as report } from "../report/route.generated.js";
360
+ import { destination as report } from "../../../../reports/$reportId/route.generated.js";
361
+ import { injectRoute } from "./route.generated.js";
291
362
 
292
- route.link({ params: { teamId: 7, id: 8 } }); // this route
293
- route.link(".", { params: { teamId: 7, id: 8 } }); // same as above
294
- route.link("/teams/$teamId/items/$id", { params: { teamId: 7, id: 9 } }); // absolute
295
- route.link("../..", { params: { teamId: 7 } }); // relative
296
- route.navigate(report, { params: { reportId: 42 } }); // another route's destination
363
+ // injectRoute() needs an injection context, such as a component field initializer.
364
+ export function itemLinks() {
365
+ const route = injectRoute();
366
+
367
+ 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 } }); // relative
371
+ void route.navigate(report, { params: { reportId: 42 } }); // another route's destination
372
+ }
297
373
  ```
298
374
 
299
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 `/`. Relative selectors start with `.`, `./` or `..`; each `..` removes one segment of the path template, not one directory or parent. TypeScript only offers selectors that resolve to a known route, and the values are always typed for the selected route.
@@ -306,10 +382,14 @@ A `destination` must come from the same generated route set as the handle. Desti
306
382
 
307
383
  `canActivate`, `canActivateChild` and `canMatch` take arrays of functions whose first argument is the parsed inputs. Angular's native arguments follow:
308
384
 
385
+ `snippets/src/routes/reports/$reportId/route.ts`
386
+
309
387
  ```ts
310
- export default defineRoute("teams/$teamId", {
311
- params: { teamId: numberParam },
312
- canActivate: [({ params }) => params.teamId > 0],
388
+ import { defineRoute, numberParam } from "@loozo/angular-router";
389
+
390
+ export default defineRoute("reports/$reportId", {
391
+ params: { reportId: numberParam },
392
+ canActivate: [({ params }) => params.reportId > 0],
313
393
  });
314
394
  ```
315
395
 
@@ -320,20 +400,37 @@ export default defineRoute("teams/$teamId", {
320
400
  | `canMatch` | This route's own path params plus all search params; no ancestor path params |
321
401
  | `canDeactivate` | Angular's native form, unchanged; no parsing |
322
402
 
323
- Guards return anything Angular accepts (`boolean`, `UrlTree`, `RedirectCommand`, or a Promise or Observable of those). If a codec fails, the navigation fails with that error before the guard runs.
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`.
324
404
 
325
- Resolvers get the parsed inputs too, and `inject()` works at the start of the callback:
405
+ 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
+
407
+ ```ts
408
+ export default defineRoute("items/$id", {
409
+ params: { id: numberParam },
410
+ onInvalidParams: (error, routeConfig, segments, partialSnapshot) =>
411
+ new RedirectCommand(inject(Router).parseUrl("/items")),
412
+ });
413
+ ```
414
+
415
+ It receives the parse error (`source: "path"`, `key`, `reason`, and the codec's error as `cause`) and Angular's `canMatch` arguments, runs in the route's injection context, and returns `false` (try the next route), a `UrlTree` or a `RedirectCommand`, or a Promise or Observable of one. It can't return `true`: that, or throwing, fails the navigation. Write `async () => false as const` rather than `async () => false`, which TypeScript widens to `boolean`.
416
+
417
+ A value that parses but doesn't exist, such as an unknown slug, is still yours to handle, for example with a `canActivate` that returns `new RedirectCommand(notFoundUrl, { browserUrl: state.url })`: Angular renders your not-found route while the address bar keeps the requested URL. The not-found URL must not match the guarded route again, or the redirect loops.
418
+
419
+ Resolvers get the parsed inputs too:
420
+
421
+ `snippets/src/routes/teams/$teamId/items/$id/route.ts`
326
422
 
327
423
  ```ts
328
424
  import { inject } from "@angular/core";
329
425
  import { defineRoute, numberParam } from "@loozo/angular-router";
330
- import team from "../team/definition.js";
331
- import { ItemCatalog } from "../../catalog.js";
426
+ import team from "../../route.js";
427
+ import { ItemCatalog } from "../../../../../catalog.js";
332
428
 
333
429
  export default defineRoute("items/$id", {
334
430
  parent: team,
335
431
  params: { id: numberParam },
336
432
  runGuardsAndResolvers: "paramsOrQueryParamsChange",
433
+ title: ({ params }) => `Item ${params.id}`,
337
434
  resolve: {
338
435
  item: ({ params, search }) => {
339
436
  const catalog = inject(ItemCatalog);
@@ -344,14 +441,18 @@ export default defineRoute("items/$id", {
344
441
  });
345
442
  ```
346
443
 
347
- Angular's default rerun policy, `paramsChange`, reruns resolvers when the path or its path/matrix params change, but not when only search params change. Set `runGuardsAndResolvers` when a resolver reads search params, as above.
444
+ `title` is a string or a callback like a resolver's that returns the title (or a Promise or Observable of it). Angular runs a title callback alongside the route's resolvers, so it can't read their results.
445
+
446
+ Angular's default rerun policy, `paramsChange`, reruns resolvers and the title callback when the path or its path/matrix params change, but not when only search params change. Set `runGuardsAndResolvers` when a resolver reads search params, as above.
348
447
 
349
448
  A redirect targets another declaration and must say what happens to search and fragment:
350
449
 
450
+ `snippets/src/routes/teams/$teamId/old-items/$id/route.ts`
451
+
351
452
  ```ts
352
453
  import { defineRoute, numberParam } from "@loozo/angular-router";
353
- import team from "../team/definition.js";
354
- import item from "../item/definition.js";
454
+ import team from "../../route.js";
455
+ import item from "../../items/$id/route.js";
355
456
 
356
457
  export default defineRoute("old-items/$id", {
357
458
  parent: team,
@@ -364,50 +465,71 @@ export default defineRoute("old-items/$id", {
364
465
  });
365
466
  ```
366
467
 
367
- 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` or `canMatch`.
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.
368
469
 
369
470
  ## Filesystem route definitions
370
471
 
371
- By default you write every declaration, including its path and parent. If you'd rather derive them from directories, set `autoDefineRoutes` on the application target:
372
-
373
- ```json
374
- "options": {
375
- "tsconfig": "tsconfig.routes.json",
376
- "scanDirectories": ["src/routes"],
377
- "outDirectory": "generated",
378
- "autoDefineRoutes": {}
379
- }
472
+ By default, directories give each route its path and parent. A directory becomes a route when it has a page or a declaration. The builder creates an editable `route.ts` in each directory that has a page but no declaration yet. Every other directory, the scan directory included, isn't a route: it adds its name to the paths of the routes below it. Each route's parent is the nearest route above it.
473
+
474
+ | Directory | Path contribution |
475
+ | ------------------ | --------------------------------------------------------------------------- |
476
+ | `products`, `$id` | Its name |
477
+ | `+layout` | `""`: a pathless route when it has a page or declaration, otherwise nothing |
478
+ | The scan directory | `""`: the root route when it has a page or declaration, otherwise nothing |
479
+ | `-helpers` | Nothing, and never a route; routes inside attach to the nearest route above |
480
+
481
+ For example, with pages in `src/routes/$package/` and `src/routes/$package/guide/$guide/` but none in `guide/`:
482
+
483
+ ```text
484
+ src/routes/
485
+ $package/
486
+ page.ts
487
+ route.ts defineRoute("$package", …): no parent
488
+ guide/ no page: adds "guide" to the path below
489
+ $guide/
490
+ page.ts
491
+ route.ts defineRoute("guide/$guide", …): parent is $package
380
492
  ```
381
493
 
382
- The builder then creates an editable `route.ts` in each directory that has a page but no declaration yet, and in the directories above it that need one. Directory names map to paths like this:
383
-
384
- | Directory | Path contribution |
385
- | ------------------ | ----------------------------------------------------------------------- |
386
- | The scan directory | `""` |
387
- | `products`, `$id` | Its name |
388
- | `+layout` | `""`: a pathless route |
389
- | `-helpers` | Nothing: no route; directories inside attach to the nearest route above |
390
-
391
- For `src/routes/products/$productId/page.ts`, the generated declaration looks like this:
494
+ The generated declaration for `$package/guide/$guide/page.ts` looks like this:
392
495
 
393
496
  ```ts
394
497
  import { defineRoute, stringParam } from "@loozo/angular-router";
395
- import parent from "../route.js";
498
+ import parent from "../../route.js";
396
499
 
397
- export default defineRoute("$productId", { parent, params: { productId: stringParam } });
500
+ export default defineRoute("guide/$guide", { parent, params: { guide: stringParam } });
398
501
  ```
399
502
 
400
- These files are yours. Change the codecs, add search params, guards and resolvers; the builder leaves them alone. When you move or rename a directory, it updates only the path literal, the `parent` import, and a param key it can rename unambiguously. When it can't make an edit safely (for example, a removed param whose codec you customized), generation fails and asks you to fix the file. It never deletes declarations.
503
+ Angular gets `{ path: ":package", children: [{ path: "guide/:guide" }] }`, the same routes as writing those two declarations by hand, so `/angular-router/guide` on its own matches neither of them. If a directory without a page needs behavior of its own, such as a guard or resolver shared by the routes below it, add a declaration to it. It then becomes a componentless route and the parent of the routes below it. Angular also matches its bare URL (`/angular-router/guide`) when no child does, and SSR prerendering lists it as a route.
504
+
505
+ These files are yours. Change the codecs, add search params, guards and resolvers; the builder leaves them alone. When you move or rename a directory, or a route above it appears or disappears, the builder updates only the path literal, the `parent` property and its import, and the local params where the change is unambiguous: it renames a single changed param key and keeps its codec, and when params are only added (for example, a declaration moved under a page-less `$team` directory) it keeps every existing codec and adds a `stringParam` codec per new param, also to a declaration that had no params. It reuses your `stringParam` import when the file has one, and otherwise imports it as `stringParam`, or `filesystemStringParam` when the file already uses that name. When it can't make an edit safely (for example, a removed param whose codec you customized, or params spread from another object), generation fails before writing anything. The error names the file, the path the directories give and the params it can't reconcile. Make that file's `params` match the directories, with one key per param in that path (or no `params` at all when the path has none), and the next run updates only the path; or move or rename the directory. Params spread from another object, kept in a shared variable or written as shorthand (`{ id }`) must first be written as plain `name: codec` properties. The builder never removes a param's codec itself, and the path always follows the directories. It never deletes declarations.
506
+
507
+ A new import goes on its own line after the file's last import and any comment on that import's line. When that comment spans lines or carries a directive for the next line (such as `// @ts-expect-error`), the new import goes before the comment instead, so the directive keeps its target. A new parent import is named `parent`, or `filesystemParent` when the file already uses that name. When an existing parent import still points at a route that's no longer the parent, for example because you added a declaration between them, it's repointed and renamed to `parent`. When it no longer resolves, because its file moved or its target was deleted, it's repointed and keeps its name. An import that's used for more than the `parent` property is left alone, and a new parent import is added beside it.
508
+
509
+ A `-` directory can't contain a page or a declaration with behavior.
401
510
 
402
- Directories without pages or declarations, like helper or empty directories, get no route. A `-` directory can't contain a page or a declaration with behavior.
511
+ Set `"autoDefineRoutes": { "filename": "definition.ts" }` on the application target to seed a different file name. It must be a plain `.ts` file name that isn't a generated file name. That only affects new files; existing declarations are found by content, whatever their name.
512
+
513
+ ### Explicit authoring
514
+
515
+ To write every path and parent yourself, turn filesystem definitions off:
516
+
517
+ ```json
518
+ "options": {
519
+ "tsconfig": "tsconfig.routes.json",
520
+ "scanDirectories": ["src/routes"],
521
+ "outDirectory": "generated",
522
+ "autoDefineRoutes": false
523
+ }
524
+ ```
403
525
 
404
- Use `{ "filename": "definition.ts" }` to seed a different file name. It must be a plain `.ts` file name that isn't a generated file name. That only affects new files; existing declarations are found by content, whatever their name.
526
+ Declarations look the same, but the builder never creates or edits them, and directory names don't affect paths or parents. File names are up to you: the builder finds declarations by their `export default defineRoute(...)` and pages by their default-exported standalone component, not by name. A page still needs a declaration in its own directory; a page without one fails generation.
405
527
 
406
528
  ## Server rendering and hydration
407
529
 
408
- The generated routes work with Angular SSR and `provideClientHydration` without extra setup; there's no transfer cache or custom renderer. Parsing and resolvers keep Angular's stability pending, so server-rendered HTML contains parsed values. A codec whose promise never settles keeps rendering pending; the package adds no timeout.
530
+ 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.
409
531
 
410
- [`examples/angular-router/ssr-proof`](../../examples/angular-router/ssr-proof/README.md) is a working SSR app with async codecs, a resolver, a redirect and hydration.
532
+ [`examples/angular-router/ssr-proof`](../../examples/angular-router/ssr-proof/README.md) is a working SSR app with a search fallback, a resolver, a redirect and hydration.
411
533
 
412
534
  ## What gets generated
413
535
 
@@ -431,26 +553,26 @@ Generation checks route structure, not your whole program. Type errors elsewhere
431
553
 
432
554
  All `@angular/build:application` options, plus:
433
555
 
434
- | Option | Type | Default | Description |
435
- | ------------------ | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
436
- | `tsconfig` | `string` | required | TypeScript config used to find route declarations. Relative to the workspace root. |
437
- | `scanDirectories` | `string[]` | required | Exactly one directory containing your routes. Relative to the `tsconfig` file's directory. |
438
- | `outDirectory` | `string` | required | Where `routes.generated.ts` and `destinations.generated.ts` go. Relative to the workspace root; must be outside the scan directory. |
439
- | `check` | `boolean` | `false` | Verify generated files without writing them. Fails on any drift. |
440
- | `autoDefineRoutes` | `{ filename?: string }` | omitted | Enable [filesystem route definitions](#filesystem-route-definitions). `filename` defaults to `route.ts`. |
556
+ | Option | Type | Default | Description |
557
+ | ------------------ | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
558
+ | `tsconfig` | `string` | required | TypeScript config used to find route declarations. Relative to the workspace root. |
559
+ | `scanDirectories` | `string[]` | required | Exactly one directory containing your routes. Relative to the `tsconfig` file's directory. |
560
+ | `outDirectory` | `string` | required | Where `routes.generated.ts` and `destinations.generated.ts` go. Relative to the workspace root; must be outside the scan directory. |
561
+ | `check` | `boolean` | `false` | Verify generated files without writing them. Fails on any drift. |
562
+ | `autoDefineRoutes` | `false \| { filename?: string }` | `{}` | [Filesystem route definitions](#filesystem-route-definitions), seeding `filename` (default `route.ts`). `false` selects [explicit authoring](#explicit-authoring). |
441
563
 
442
- The builder runs generation once, then the official application build. Invalid options stop the build before generation starts, and generation errors stop it before the Angular build runs. `watch: true` is rejected; use the serve builder for watching.
564
+ The builder runs generation once, then the official application build. Invalid options stop the build before generation starts. Generation errors, including drift in check mode, are logged and fail the build before the Angular build runs. `watch: true` is rejected; use the serve builder for watching.
443
565
 
444
566
  ### `@loozo/angular-router:serve`
445
567
 
446
- All `@angular/build:dev-server` options, plus:
568
+ All `@angular/build:dev-server` options, set in `angular.json` or on the command line (`ng serve --port 4300`), plus:
447
569
 
448
570
  | Option | Type | Default | Description |
449
571
  | ------------- | ----------------- | -------- | ------------------------------------------------------------------------------------ |
450
572
  | `buildTarget` | `string` | required | A target that uses `@loozo/angular-router:application`. Router options come from it. |
451
573
  | `poll` | `number \| false` | `1000` | File-watch polling interval in milliseconds, or `false` for no default. |
452
574
 
453
- The serve builder validates its dev-server options and the build target before generating anything. It watches the scan directory and regenerates when files are added, changed or removed. The initial generation must succeed before the dev server starts; later errors are logged, and the server keeps running until you fix them. The build target must not resolve `check: true`, because serving always writes generated files.
575
+ Invalid options stop the serve builder before it generates anything, and so does an invalid build target. It watches the scan directory and regenerates when files are added, changed or removed. The initial generation must succeed before the dev server starts; if it fails, the error is logged and the builder reports a failed build. Later errors are logged, and the server keeps running until you fix them. The build target must not resolve `check: true`, because serving always writes generated files.
454
576
 
455
577
  Polling is on by default because Angular 22.2.2's native file watcher misses edits to files inside a renamed directory, which leaves the dev server serving stale output. Polling costs some idle CPU (about 3% of one core on the example app at 1000 ms, against under 0.1% without), and more on larger workspaces.
456
578