@loozo/angular-router 0.0.2 → 0.0.3

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 (37) hide show
  1. package/README.md +442 -79
  2. package/builders.json +2 -2
  3. package/fesm2022/loozo-angular-router-_core.mjs +348 -589
  4. package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
  5. package/fesm2022/loozo-angular-router-_generated.mjs +492 -617
  6. package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
  7. package/fesm2022/loozo-angular-router.mjs +2 -19
  8. package/node/_core/src/path.d.ts +17 -0
  9. package/node/src/builders/application.js +9 -6
  10. package/node/src/builders/options.js +38 -58
  11. package/node/src/builders/serve.js +51 -8
  12. package/node/src/generation/anchor.d.ts +13 -0
  13. package/node/src/generation/anchor.js +92 -0
  14. package/node/src/generation/compiler.d.ts +6 -0
  15. package/node/src/generation/compiler.js +68 -0
  16. package/node/src/generation/emit.d.ts +3 -0
  17. package/node/src/generation/files.d.ts +26 -0
  18. package/node/src/generation/files.js +14 -3
  19. package/node/src/generation/filesystem.d.ts +7 -0
  20. package/node/src/generation/generate.d.ts +16 -0
  21. package/node/src/generation/generate.js +5 -4
  22. package/node/src/generation/index.d.ts +16 -0
  23. package/node/src/generation/index.js +15 -0
  24. package/node/src/generation/overlay.d.ts +3 -0
  25. package/node/src/generation/selectors.d.ts +2 -0
  26. package/node/src/generation/sources.d.ts +50 -0
  27. package/node/src/generation/sources.js +50 -10
  28. package/node/src/generation/validate.d.ts +12 -0
  29. package/node/src/generation/validate.js +38 -33
  30. package/package.json +7 -2
  31. package/schemas/application.json +743 -8
  32. package/schemas/serve.json +11 -32
  33. package/types/loozo-angular-router-_core.d.ts +159 -181
  34. package/types/loozo-angular-router-_generated.d.ts +131 -109
  35. package/types/loozo-angular-router.d.ts +2 -1
  36. package/.npmignore +0 -3
  37. package/fesm2022/loozo-angular-router.mjs.map +0 -1
package/README.md CHANGED
@@ -1,144 +1,507 @@
1
1
  # @loozo/angular-router
2
2
 
3
- Typed route declarations, codecs, URLs and navigation on top of Angular’s native router. Static generation supplies route configuration, typed destinations and route handles without introducing a second router. Angular owns matching, activation, guards, resolvers, redirects, history and stability.
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.
4
4
 
5
- This is an experimental implementation. The reviewed rebuild passed all 126 cases in the approved acceptance copy, plus source review and native consumer/build/browser checks. It is not yet broadly battle-tested. Version `0.0.2` is experimental and MIT-licensed.
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
+
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
+
9
+ **Status:** experimental and not yet broadly battle-tested. Version `0.0.3`, MIT license.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add @loozo/angular-router
15
+ ```
16
+
17
+ Peer dependencies:
18
+
19
+ | Package | Range |
20
+ | ----------------- | --------- |
21
+ | `@angular/core` | `^22.0.0` |
22
+ | `@angular/router` | `^22.0.0` |
23
+ | `@angular/build` | `~22.2.0` |
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.
26
+
27
+ ## Setup
28
+
29
+ Generation runs inside two Angular builders that replace your project's `build` and `serve` builders. Both run the official Angular builders (`@angular/build:application` and `@angular/build:dev-server`) after generating, so everything else about your build stays the same.
30
+
31
+ In `angular.json`:
32
+
33
+ ```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" }
50
+ }
51
+ ```
52
+
53
+ The application builder takes the router options (`tsconfig`, `scanDirectories`, `outDirectory`, and optionally `check` and `autoDefineRoutes`) next to your normal Angular application options, which it passes through unchanged. The serve builder takes normal dev-server options and reads the router options from its `buildTarget`. See [Builder options](#builder-options).
54
+
55
+ 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
+
57
+ ```json
58
+ {
59
+ "extends": "./tsconfig.app.json",
60
+ "files": [],
61
+ "include": ["src/**/*.ts"]
62
+ }
63
+ ```
64
+
65
+ Your app's `tsconfig.app.json` must include the generated files, for example `"include": ["src/**/*.ts", "generated/**/*.ts"]`.
66
+
67
+ Then install the generated routes with Angular's `provideRouter`:
68
+
69
+ ```ts
70
+ import { bootstrapApplication } from "@angular/platform-browser";
71
+ import { provideRouter } from "@angular/router";
72
+ import Shell from "./shell.js";
73
+ import { routes } from "../generated/routes.generated.js";
74
+
75
+ void bootstrapApplication(Shell, { providers: [provideRouter(routes)] });
76
+ ```
77
+
78
+ Use the Angular CLI as usual:
79
+
80
+ ```sh
81
+ ng build # generate, then the official application build
82
+ ng serve # generate and watch, then the official dev server
83
+ ng build --configuration ci # check generated files without writing them, then build
84
+ ```
85
+
86
+ Commit the generated files along with your route declarations, and run the `ci` configuration in CI so drift fails the build.
87
+
88
+ ## Quick start
89
+
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.
6
91
 
7
92
  ```ts
8
- import {
9
- defineRoute,
10
- createUrl,
11
- numberParam,
12
- enumParam,
13
- optionalSearchParam,
14
- } from "@loozo/angular-router";
15
-
16
- const product = defineRoute("products/$productId", {
93
+ // src/routes/product/definition.ts
94
+ import { defineRoute, enumParam, numberParam, optionalSearchParam } from "@loozo/angular-router";
95
+
96
+ export default defineRoute("products/$productId", {
17
97
  params: { productId: numberParam },
18
98
  search: { tab: optionalSearchParam(enumParam(["details", "reviews"])) },
19
99
  fragments: ["summary", "reviews"],
20
100
  });
101
+ ```
102
+
103
+ ```ts
104
+ // src/routes/product/page.ts
105
+ import { Component } from "@angular/core";
106
+ import { RouterLink } from "@angular/router";
107
+ import { injectRoute } from "./route.generated.js";
108
+
109
+ @Component({
110
+ imports: [RouterLink],
111
+ 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
+ }
120
+ <a [routerLink]="reviews">Reviews</a>
121
+ `,
122
+ })
123
+ export default class Product {
124
+ readonly route = injectRoute();
125
+ readonly reviews = this.route.link({
126
+ params: { productId: 42 },
127
+ search: { tab: "reviews" },
128
+ fragment: "reviews",
129
+ });
130
+ }
131
+ ```
132
+
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`.
134
+
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.
136
+
137
+ ## Declaring routes
138
+
139
+ ```ts
140
+ defineRoute(path, config);
141
+ ```
142
+
143
+ `path` must be a string literal; TypeScript infers everything else without generic arguments.
144
+
145
+ ### Paths
146
+
147
+ Paths are decoded segment text, not URLs:
148
+
149
+ | Path | Meaning |
150
+ | ----------------------- | ------------------------------------------------------------------------ |
151
+ | `""` | Root, or a pathless route when it has a parent |
152
+ | `"products"` | A static segment |
153
+ | `"products/$productId"` | `$name` as a whole segment declares a path param |
154
+ | `"docs/$segments[]"` | A terminal catchall: zero or more remaining segments, parsed as an array |
155
+ | `"$id/compare/$id"` | A repeated param uses the same value in every position |
156
+
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.
158
+
159
+ ### Config
160
+
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 |
171
+
172
+ Structural keys such as `path`, `children`, `component`, `loadComponent`, `redirectTo`, `pathMatch`, `matcher` and `outlet` are rejected: generation owns them.
173
+
174
+ ### Parents
175
+
176
+ ```ts
177
+ // src/routes/team/definition.ts
178
+ export default defineRoute("teams/$teamId", {
179
+ params: { teamId: numberParam },
180
+ search: { score: optionalSearchParam(numberParam) },
181
+ });
182
+
183
+ // src/routes/item/definition.ts
184
+ import team from "../team/definition.js";
185
+
186
+ export default defineRoute("items/$id", {
187
+ parent: team,
188
+ params: { id: numberParam },
189
+ });
190
+ ```
191
+
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.
193
+
194
+ ### Codecs
195
+
196
+ | Codec | Value |
197
+ | ---------------------------- | ---------------------------------------------------------------------------- |
198
+ | `stringParam` | Any string, including `""` |
199
+ | `numberParam` | A finite number (`" 42 "`, `"1e3"` and `"0x10"` parse; blank and `NaN` fail) |
200
+ | `booleanParam` | Exactly `true` or `false` |
201
+ | `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` |
204
+
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[]`.
206
+
207
+ A custom codec implements `ParamCodec<Value>`. `parse` receives the already-decoded text and may be async; `stringify` must be synchronous:
208
+
209
+ ```ts
210
+ import type { ParamCodec } from "@loozo/angular-router";
211
+
212
+ export const slug: ParamCodec<string> = {
213
+ parse(raw) {
214
+ if (!/^[a-z0-9-]+$/.test(raw)) {
215
+ throw new Error("invalid slug");
216
+ }
217
+
218
+ return raw;
219
+ },
220
+ stringify(value) {
221
+ return value;
222
+ },
223
+ };
224
+ ```
225
+
226
+ `defineRoute` never calls parsers, guards, resolvers or other callbacks; it only records them.
227
+
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.
229
+
230
+ ### Fragments
231
+
232
+ `fragments: ["summary", "reviews"]` lists the fragment names you can pass when creating a URL to this route. Without the list (or with an empty list), the `fragment` option is not allowed. The names belong to this route only; children don't inherit them. Incoming hashes aren't validated, and the package doesn't scroll: provide matching element IDs and configure Angular's scrolling yourself.
233
+
234
+ ## Building URLs
235
+
236
+ `createUrl` builds a root-relative URL from a declaration, synchronously and without running any parsers:
237
+
238
+ ```ts
239
+ import { createUrl } from "@loozo/angular-router";
240
+ import product from "./routes/product/definition.js";
21
241
 
22
242
  createUrl(product, {
23
243
  params: { productId: 42 },
24
244
  search: { tab: "reviews" },
25
245
  fragment: "summary",
26
- }); // /products/42?tab=reviews#summary
246
+ }); // "/products/42?tab=reviews#summary"
27
247
  ```
28
248
 
29
- Root runtime exports are exactly `defineRoute`, `createUrl`, `stringParam`, `numberParam`, `booleanParam`, `enumParam`, `optionalSearchParam`, `nullableSearchParam`. Public types are `Route` and `ParamCodec`. `_core` is internal package coupling, not an application metadata/graph API.
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:
30
250
 
31
- Paths are decoded authored text. `$id` consumes one logical value (repeated local occurrences reuse that value); terminal `$parts[]` consumes a required readonly array, including `[]`. Parent declarations inherit input ownership through pathless nodes. Catchalls cannot have children. Search keys are globally sorted; fragment names are local, never inherited. Codecs stringify synchronously; no parsing or native callbacks run during declaration/URL creation.
251
+ ```ts
252
+ const docs = defineRoute("docs/$segments[]", { params: { segments: stringParam } });
32
253
 
33
- Route membership is per physical package instance. Routes and owned containers are shallow-frozen; application codecs, callbacks/providers and opaque values retain identity and are not recursively frozen. `Route<Path, Params, Search, Resolvers = {}>` has readonly optional input/data witnesses. Callback inputs infer effective params/search, except matching guards see only local positional params. Local data unwraps Promise/Observable outputs and excludes RedirectCommand.
254
+ createUrl(docs, { params: { segments: ["guides", "setup"] } }); // "/docs/guides/setup"
255
+ createUrl(docs, { params: { segments: [] } }); // "/docs"
256
+ ```
34
257
 
35
- - [Public contract](SPEC.md)
36
- - [Consumer example](../../examples/angular-router/)
37
- - [Handwritten SSR/hydration proof](../../examples/angular-router/ssr-proof/README.md)
258
+ Pass `{}` for a route without params or search. Wrong values fail to type-check. Unknown keys fail to type-check in an object literal; an object passed through a variable can carry extra keys past TypeScript, but `createUrl` still throws a `TypeError` for them at runtime.
38
259
 
39
- ## Commands
260
+ ## Reading inputs and navigating
40
261
 
41
- Prerequisites: Node 24.21.0, pnpm 12.9.1, ordinary workspace installation. Run installation from the workspace with `pnpm_config_pm_on_fail=error pnpm_config_verify_deps_before_run=error pnpm install --frozen-lockfile`.
262
+ Each route that has a page or descendants with pages gets a colocated `route.generated.ts`; redirect routes, and parents left with only redirects below them, get none. It exports two things:
42
263
 
43
- From this package directory:
264
+ - `injectRoute()`: call it in an injection context (a field initializer or constructor) to get the handle for this route.
265
+ - `destination`: an inert reference to this route that other routes can link to.
44
266
 
45
- ```sh
46
- pnpm run build # NodeNext tsc + native ng-packagr primary/_core/_generated APF
47
- pnpm run check # no-fix source formatting/lint checks
48
- pnpm run typecheck # strict source check, no output
49
- pnpm run typecheck:consumers # strict NodeNext and ESNext/Bundler against actual APF
50
- pnpm run test # public-root runtime + published/generated documentation regressions
51
- pnpm run test:root # public-root runtime regressions; plain Node
52
- pnpm run test:docs # TSDoc coverage in published declarations and generated examples
53
- pnpm run test:application /absolute/task-owned/absent-evidence-directory
54
- pnpm run test:navigation /absolute/task-owned/absent-evidence-directory
55
- pnpm run test:lifecycle /absolute/task-owned/absent-evidence-directory
56
- pnpm run test:ssr /absolute/task-owned/absent-evidence-directory
57
- pnpm run test:filesystem /absolute/task-owned/absent-evidence-directory
58
- node tests/packed.mjs /absolute/task-owned/fresh-evidence-directory
267
+ ```ts
268
+ import { injectRoute } from "./route.generated.js";
269
+
270
+ const route = injectRoute();
271
+
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
59
275
  ```
60
276
 
61
- Build cleans/emits only package `node/`, then ng-packagr cleans/emits `dist/` and uses package `node_modules/.cache/ng-packagr`; it cannot generate/repair application files. Private Node generation/builders use ordinary NodeNext tsc; manifests/schemas/Node JS are copied as APF assets. Source and packed builder manifests use the same relative paths. Source metadata points to built APF output; ng-packagr rewrites dist metadata to its own paths. Packed verification writes only the supplied fresh evidence directory, packs the actual dist manifest, checks the handwritten TypeScript consumer under NodeNext and ESNext/Bundler, nominal declaration brands, eight-value namespace, plain-Node loading and distinct physical instances. It reuses actual installed dependency bindings; there is no loader/compiler adapter or publication.
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.
62
278
 
63
- ## Authored application → native Angular
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.
64
280
 
65
- The application installs `routes` from `generated/routes.generated.js` with native `provideRouter`. The companion targets in `examples/angular-router/angular.json` generate only exact-name marked artifacts and delegate directly to the official application/dev-server targets. Source candidates are native tsconfig roots intersected with configured scans; analysis never imports/evaluates app modules. Definitions/pages remain application-owned. Each companion requires exactly one `scanDirectories` entry, including in explicit-authoring mode. `_generated` is internal generator coupling, not a supported graph API.
281
+ `data` holds this route's own resolver results only, not static `data`, parent data or the title.
66
282
 
67
- From the workspace root, after package build:
283
+ Parsing keeps Angular's stability pending until the values are published, so server rendering waits for async codecs.
68
284
 
69
- ```sh
70
- pnpm --filter @loozo/angular-router run example:build # generation + official Angular build
71
- pnpm --filter @loozo/angular-router run example:check # read-only generation check + build
72
- pnpm --filter @loozo/angular-router run example:serve # generation + official dev-server
285
+ ### Links and navigation
286
+
287
+ `link` returns an Angular `UrlTree` for `RouterLink`; `navigate` calls `Router.navigateByUrl` and returns Angular's own promise. Both take the same arguments:
288
+
289
+ ```ts
290
+ import { destination as report } from "../report/route.generated.js";
291
+
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
73
297
  ```
74
298
 
75
- The colocated facade exports parameterless `injectRoute()` and an inert `destination`. Definition getters/reference construction avoid eager ESM binding reads. Normal lazy page imports are extensionless direct import promises; emitted `.js` is used only when native Bundler resolution needs it to preserve the discovered identity.
299
+ 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.
76
300
 
77
- Durable public Architect/filesystem/real-browser development regression (requires `/usr/bin/chromium`):
301
+ 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.
78
302
 
79
- ```sh
80
- pnpm --filter @loozo/angular-router run test:application /absolute/task-owned/absent-evidence-directory
303
+ A `destination` must come from the same generated route set as the handle. Destinations are for `link` and `navigate` only; `createUrl` takes declarations.
304
+
305
+ ## Guards, resolvers and redirects
306
+
307
+ `canActivate`, `canActivateChild` and `canMatch` take arrays of functions whose first argument is the parsed inputs. Angular's native arguments follow:
308
+
309
+ ```ts
310
+ export default defineRoute("teams/$teamId", {
311
+ params: { teamId: numberParam },
312
+ canActivate: [({ params }) => params.teamId > 0],
313
+ });
314
+ ```
315
+
316
+ | Guard | Inputs |
317
+ | ------------------ | ---------------------------------------------------------------------------- |
318
+ | `canActivate` | This route's params and search, including inherited ones |
319
+ | `canActivateChild` | The declaring (parent) route's params and search |
320
+ | `canMatch` | This route's own path params plus all search params; no ancestor path params |
321
+ | `canDeactivate` | Angular's native form, unchanged; no parsing |
322
+
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.
324
+
325
+ Resolvers get the parsed inputs too, and `inject()` works at the start of the callback:
326
+
327
+ ```ts
328
+ import { inject } from "@angular/core";
329
+ import { defineRoute, numberParam } from "@loozo/angular-router";
330
+ import team from "../team/definition.js";
331
+ import { ItemCatalog } from "../../catalog.js";
332
+
333
+ export default defineRoute("items/$id", {
334
+ parent: team,
335
+ params: { id: numberParam },
336
+ runGuardsAndResolvers: "paramsOrQueryParamsChange",
337
+ resolve: {
338
+ item: ({ params, search }) => {
339
+ const catalog = inject(ItemCatalog);
340
+
341
+ return catalog({ teamId: params.teamId, id: params.id, score: search.score });
342
+ },
343
+ },
344
+ });
81
345
  ```
82
346
 
83
- The evidence-directory argument is required and must not exist. This creates an owned consumer copy, checks concrete facade-factory provenance, literal module-reference preflight, native exact/wildcard ambient helpers, drift priority/nonwrites/byte-identical modes/nanosecond mtimes, and native watch/repair/stop. Its page.js.ts + named-only page.ts collision is built by real Angular and rendered in Chromium.
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.
84
348
 
85
- ## Opt-in filesystem definitions
349
+ A redirect targets another declaration and must say what happens to search and fragment:
86
350
 
87
- Both companion targets accept `autoDefineRoutes: { filename?: string }`. Omit it for explicit authoring; `{}` enables editable `route.ts` seeds. A custom simple implementation `.ts` basename affects only new seeds: existing declarations are discovered by content/provenance, never renamed by this option. Declaration/generated filenames cannot collide.
351
+ ```ts
352
+ import { defineRoute, numberParam } from "@loozo/angular-router";
353
+ import team from "../team/definition.js";
354
+ import item from "../item/definition.js";
355
+
356
+ export default defineRoute("old-items/$id", {
357
+ parent: team,
358
+ params: { id: numberParam },
359
+ redirect: {
360
+ to: item,
361
+ search: { mode: "drop" }, // or "preserve", or { mode: "replace", values }
362
+ fragment: { mode: "drop" }, // or "preserve", or { mode: "replace", value }
363
+ },
364
+ });
365
+ ```
366
+
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`.
368
+
369
+ ## Filesystem route definitions
370
+
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:
88
372
 
89
373
  ```json
90
- {
374
+ "options": {
91
375
  "tsconfig": "tsconfig.routes.json",
92
376
  "scanDirectories": ["src/routes"],
93
377
  "outDirectory": "generated",
94
- "delegateTarget": "proof:official",
95
378
  "autoDefineRoutes": {}
96
379
  }
97
380
  ```
98
381
 
99
- The scan root has path `""`. Plain directories contribute their basename, `+layout` contributes `""`, and `-helpers` is transparent (no record; descendants attach to the nearest included ancestor). Admitted pages/declarations and necessary included ancestors participate; unused helper/empty directories do not. Transparent directories cannot own pages or declarations with behavior. Editable files are never automatically deleted.
100
-
101
- Seeds use ordinary `defineRoute`, default-imported parents and local `stringParam` codecs (per element for `$parts[]`). Moves/renames synchronize only the path literal, parent field/import and unambiguous local-param keys. An inline one-param rename preserves its exact codec expression. Opaque/ambiguous codec changes and stale parent imports with unrelated uses fail rather than guessing; callback/navigation input accesses are not renamed. All other authored expressions and bytes, including UTF-8 BOM and CRLF, are preserved; new static imports are appended at the complete module EOF boundary, retaining all existing statement/comment attachments, shebangs and triple-slash references. Native ESM import bindings are hoisted; new seed files still place their imports at the top. Removing an authored local codec requires explicit author repair. Parent imports/properties with directives in leading trivia, inside the removed syntax or in adjacent retained trivia up to the next actual token/EOF are not deleted: repair them explicitly before synchronization. This includes compiler-control pragmas (`@ts-check`/`@ts-nocheck`, triple-slash reference/AMD and JSX pragmas), which must not be activated by retaining a dormant comment at module start. Ordinary comments are retained when obsolete syntax is removed; adjacent trivia is validated only, never copied into the replacement or deleted.
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:
102
383
 
103
- The compiler overlays all planned seeds/patches and generated artifacts before publication. Native tsconfig membership/exclusions (including explicit files and wildcard includes through permitted external workspace-access aliases), source syntax/provenance, admitted declaration-file semantic diagnostics, tree/matching rules, application module references and filesystem/ownership checks must all pass before any source or generated write. Whole-program semantic, compiler option/global and Angular/template checks remain native build responsibilities: unrelated type errors may fail the delegate after valid source/generated changes have been published. `check: true` validates the same result, reports editable/generated drift and writes nothing. Byte-identical files retain nanosecond mtimes. Publication remains individual file writes, not a crash/concurrent-writer transaction. Alias substitution ends at the canonical workspace boundary: links inside it remain unsafe source/root suffixes even when they point back to the workspace itself.
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 |
104
390
 
105
- `test:filesystem` uses fresh owned evidence, public Architect and native Angular builds/Chromium/dev-server to prove seeding, filename-independent discovery, structural moves, codec typing, conservative preflight nonwrites and watch/add/change/remove/repair/stop.
106
-
107
- ## Generated selection → native navigation
391
+ For `src/routes/products/$productId/page.ts`, the generated declaration looks like this:
108
392
 
109
393
  ```ts
110
- import { injectRoute } from "./route.generated.js";
111
- import { destination as report } from "../report/route.generated.js";
394
+ import { defineRoute, stringParam } from "@loozo/angular-router";
395
+ import parent from "../route.js";
112
396
 
113
- const route = injectRoute(); // parameterless, noncallable; readonly native Resources
114
- route.link(".", { params: { scopeId: "x" } }); // exact declaration schema
115
- route.link("./", { params: { scopeId: "x" }, search: { leaf: "required" } }); // canonical winner at this path
116
- route.link("/reports/$reportId", { params: { reportId: 42 } }); // literal template, not concrete URL
117
- route.navigate(report, { params: { reportId: 42 } }); // exact exported object declaration
397
+ export default defineRoute("$productId", { parent, params: { productId: stringParam } });
118
398
  ```
119
399
 
120
- Every origin's absolute and URL-segment-relative selections are computed statically into one shared artifact; colocated facades do not duplicate graph tables. Relative climbs count authored template segments, not directories or parent edges. Unknown/intermediate/nonwinning paths, over-climbs, mixed traversal and query/hash relative suffixes are absent. Catchall origins expose exact-current and absolute/reference targets, never depth-dependent relative navigation. Absolute redirect-source selections serialize their source inputs.
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.
401
+
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.
403
+
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.
405
+
406
+ ## Server rendering and hydration
407
+
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.
409
+
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.
411
+
412
+ ## What gets generated
413
+
414
+ | File | Contents |
415
+ | ------------------------------------------ | ------------------------------------------------------------- |
416
+ | `<outDirectory>/routes.generated.ts` | `routes: Routes` for `provideRouter` |
417
+ | `<outDirectory>/destinations.generated.ts` | Shared typing and navigation data (when any route has a page) |
418
+ | `route.generated.ts` beside each route | `injectRoute()` and `destination`; none for redirect routes |
419
+
420
+ 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.
421
+
422
+ 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.
423
+
424
+ With `check: true`, the builder computes the same result but writes nothing. It fails if any generated file is changed, missing or stale, and the Angular build doesn't run.
121
425
 
122
- Selectors must be single known literals (not widened strings/unions). Values retain the selected target's codecs, required/optional search modifiers and own fragments, with no current-input reuse or third/extras argument. References have a module-local type set identity and authenticate at runtime through weak ownership plus the exact stable generated set object; byte-identical foreign sets and clones are not equivalent. No route expression is parsed against a graph at runtime. Validation/serialization is synchronous before Angular: link calls native parseUrl; navigate calls native navigateByUrl and returns that identical Promise.
426
+ Generation checks route structure, not your whole program. Type errors elsewhere still show up in the Angular build itself.
123
427
 
124
- `test:navigation` uses handwritten strict generated consumers and genuine Architect/Angular/Chromium to check type negatives (including optional prototype-named keys), canonical/exact/reference targets, foreign sets, invalid-call non-entry, native Promise/RouterLink behavior and unrelated facade stability. Like `test:application`, it requires a fresh absent evidence directory.
428
+ ## Builder options
125
429
 
126
- ## Native inputs and lifecycle
430
+ ### `@loozo/angular-router:application`
127
431
 
128
- Exact declarations/complete snapshots share one frozen parsed-input operation; matching candidates share a separate local-position/effective-query operation. All raw cardinalities/types are checked before independent codecs launch. Guards/resolvers enter in the captured native Injector only while subscribed; Angular owns ordering, first values, empty/redirect/error semantics and teardown. Local data projects only own native results among declared string and symbol resolver keys, preserving own `undefined` and opaque values; inherited or missing results are not fabricated. Native rerun policy remains authoritative.
432
+ All `@angular/build:application` options, plus:
129
433
 
130
- Angular 22.1.5 compatibility footnote: Angular’s resolved-data accumulator loses a resolver result named `__proto__`. The handle follows native behavior: it omits the missing value rather than fabricating inherited data. This is an upstream Angular issue, not an added package restriction or an alpha-release blocker. No transport workaround or result cache is added.
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`. |
131
441
 
132
- Each handle owns paired params/search visibility and independent local data, projected into real readonly native Resources. Navigation hides old values; cancel/skip restores the true survivor without reparsing. Commitment bookkeeping is separate from visibility, including reentrant Start8→End7→Cancel8. Relevant handle work holds native PendingTasks, publishes before release, and releases on error/obsolete commitment/destruction. Constructor-time task handoff covers handles created inside native terminal listeners. Promises are not abortable; late failures are observed and cannot publish obsolete output. No URL/data cache, timeout, or second router exists.
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.
133
443
 
134
- `test:lifecycle` uses actual Architect/Angular/Chromium and public Router/ApplicationRef/PendingTasks with asynchronous gates, a never-settling obsolete survivor, terminal-listener construction, own string/symbol data, nearest declaring-parent ancestry, injector destruction during task acquisition, same-URL skip reentry, iframe/host Promise guards/resolvers, and synchronous preserved-search failure with a later source rejection. Like the other public tests it requires a fresh absent evidence-directory argument.
444
+ ### `@loozo/angular-router:serve`
135
445
 
136
- The independent rebuild acceptance suite and its preserved results live outside this product repository. Full acceptance passed against the reviewed rebuild using the explicitly approved v2 copy. That copy corrects two fixture assumptions: an already-current native redirect resolves `false`, and the SSR route TypeScript program needs Node ambient types. Original inputs and earlier failed results remain preserved separately; this is not a claim that the unchanged original suite passed. The supplied handwritten home consumer remains unchanged.
446
+ All `@angular/build:dev-server` options, plus:
137
447
 
138
- ## Native SSR and hydration proof
448
+ | Option | Type | Default | Description |
449
+ | ------------- | ----------------- | -------- | ------------------------------------------------------------------------------------ |
450
+ | `buildTarget` | `string` | required | A target that uses `@loozo/angular-router:application`. Router options come from it. |
451
+ | `poll` | `number \| false` | `1000` | File-watch polling interval in milliseconds, or `false` for no default. |
139
452
 
140
- `examples/angular-router/ssr-proof/` is a separate handwritten Angular application. Componentless team inputs, delayed item/score codecs (75 ms), local deterministic catalog resolution, a query/hash-dropping alias, and a resolver-free delayed handle page use genuine generated facades. Shared configuration uses native hydration with no HTTP transfer cache and no blocking initial navigation.
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.
141
454
 
142
- `test:ssr` copies that actual application into the required fresh absent evidence directory, runs ordinary Architect production builds and read-only generation checks, and checks strict native TypeScript/Angular templates. Built `AngularNodeAppEngine` + Node HTTP demonstrate native302, matched-position matrix isolation, concurrent request isolation and handle-only stability. Official dev-server + Chromium preserve exact original SSR document/element/text Nodes during hydration, then reuse the same component/DOM for typed alias and query-only SPA navigation. Native production metafile evidence checks browser closure and genuinely lazy page chunks. Servers/browser/Architect runs are stopped in `finally`; raw HTTP HTML/headers, logs, stats and observations remain inspectable.
455
+ 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.
143
456
 
144
- This is a bounded SSR/hydration proof, not cross-browser certification or exhaustive request/fault coverage. The Node entry is a loopback SSR HTTP proof server, not a production static-asset deployment server; use official companion serve for browser demonstration. See the [SSR proof README](../../examples/angular-router/ssr-proof/README.md).
457
+ To go back to native file events, set `"poll": false` in the serve target or one of its configurations. The router then adds no default, and Angular's own configuration applies, including a `poll` set on the build target. A positive number overrides the build target's `poll`. `0` is rejected. On the command line, `--poll` accepts milliseconds only; set `false` in `angular.json`.
458
+
459
+ ## Limitations
460
+
461
+ - No standalone generation CLI or public route-graph API. Generate through the two builders.
462
+ - Route and page files must be `.ts`; `.mts`, `.cts` and `.tsx` are ignored.
463
+ - Primary outlet only: no named outlets, custom matchers or `loadChildren`.
464
+ - One scan directory per application.
465
+ - No data, URL or transfer caches. Resolver data lives in Angular.
466
+ - Relative navigation from a catchall route isn't supported; use `"."` or an absolute selector.
467
+ - Angular 22.2.2 drops a resolver result named `__proto__`, so a resolver with that name has no value in `data`.
468
+ - Writes are individual file replacements. A write error or an interrupted run can leave mixed output; the next successful run repairs it.
469
+
470
+ ## Further reading
471
+
472
+ - [`SPEC.md`](SPEC.md): the full contract, including every edge case
473
+ - [`examples/angular-router`](../../examples/angular-router/): a minimal app using the builders
474
+ - [`examples/angular-router/ssr-proof`](../../examples/angular-router/ssr-proof/README.md): SSR and hydration
475
+
476
+ ## Developing this package
477
+
478
+ Requires Node 24.21.0 and pnpm 12.9.1. Install from the workspace root with `pnpm_config_pm_on_fail=error pnpm_config_verify_deps_before_run=error pnpm install --frozen-lockfile`. From this directory:
479
+
480
+ ```sh
481
+ pnpm run build # Node builders/generation (tsc), then the Angular package (ng-packagr)
482
+ pnpm run check # formatting and lint
483
+ pnpm run typecheck # strict source typecheck
484
+ pnpm run typecheck:consumers # consumer typecheck against the built package (NodeNext and Bundler)
485
+ pnpm run test # root runtime, published-docs and schema tests
486
+ ```
487
+
488
+ `build` regenerates `schemas/application.json` from the installed `@angular/build`; `pnpm test` fails when the committed schema is stale.
489
+
490
+ The browser tests use real Angular builds and Chromium (`/usr/bin/chromium`). Each takes a fresh absolute directory, which must not exist yet, for its evidence:
491
+
492
+ ```sh
493
+ pnpm run test:application /absolute/new-directory
494
+ pnpm run test:navigation /absolute/new-directory
495
+ pnpm run test:lifecycle /absolute/new-directory
496
+ pnpm run test:ssr /absolute/new-directory
497
+ pnpm run test:filesystem /absolute/new-directory
498
+ node tests/packed.mjs /absolute/new-directory # packed-package consumer check
499
+ ```
500
+
501
+ To run the example app from the workspace root after building the package:
502
+
503
+ ```sh
504
+ pnpm --filter @loozo/angular-router run example:build # generate + build
505
+ pnpm --filter @loozo/angular-router run example:check # check generated files + build
506
+ pnpm --filter @loozo/angular-router run example:serve # generate + dev server
507
+ ```