@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
@@ -7,29 +7,104 @@ import { specifier } from "./emit.js";
7
7
  export function transparent(root, directory) {
8
8
  return directory !== root && basename(directory).startsWith("-");
9
9
  }
10
- function localPath(root, directory) {
11
- if (directory === root || basename(directory).startsWith("+")) {
10
+ // Directive comments bind to a following line or the module start, so edits must not move them.
11
+ const directive = /@ts-(?:expect-error|ignore|check|nocheck)\b|@jsx(?:frag|importsource|runtime)?\b|\/\/\/\s*<(?:reference|amd-dependency|amd-module)\b|(?:eslint|oxlint)-disable-next-line\b/i;
12
+ function contribution(root, directory) {
13
+ if (directory === root || transparent(root, directory) || basename(directory).startsWith("+")) {
12
14
  return "";
13
15
  }
14
16
  return basename(directory);
15
17
  }
16
- function unusedBinding(source, preferred) {
17
- const identifiers = new Set();
18
+ // Names that bindings, declarations and references use; property names are not bindings.
19
+ function identifiers(source) {
20
+ const names = new Set();
18
21
  function visit(node) {
19
- if (ts.isIdentifier(node)) {
20
- identifiers.add(node.text);
22
+ const propertyName = (ts.isPropertyAssignment(node.parent) || ts.isPropertyAccessExpression(node.parent)) &&
23
+ node.parent.name === node;
24
+ if (ts.isIdentifier(node) && !propertyName) {
25
+ names.add(node.text);
21
26
  }
22
27
  ts.forEachChild(node, visit);
23
28
  }
24
- visit(source);
29
+ ts.forEachChild(source, visit);
30
+ return names;
31
+ }
32
+ function unusedBinding(source, preferred) {
33
+ const used = identifiers(source);
25
34
  let name = preferred;
26
35
  let index = 0;
27
- while (identifiers.has(name)) {
36
+ while (used.has(name)) {
28
37
  index += 1;
29
38
  name = `${preferred}${index}`;
30
39
  }
31
40
  return name;
32
41
  }
42
+ function parentBinding(source) {
43
+ if (identifiers(source).has("parent")) {
44
+ return unusedBinding(source, "filesystemParent");
45
+ }
46
+ return "parent";
47
+ }
48
+ // Reuse a stringParam import from the router, else import it as `stringParam` when that
49
+ // identifier is free.
50
+ function stringParamBinding(source, imports) {
51
+ for (const statement of source.statements) {
52
+ if (!ts.isImportDeclaration(statement) ||
53
+ statement.importClause?.isTypeOnly ||
54
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
55
+ statement.moduleSpecifier.text !== "@loozo/angular-router") {
56
+ continue;
57
+ }
58
+ const bindings = statement.importClause?.namedBindings;
59
+ if (!bindings || !ts.isNamedImports(bindings)) {
60
+ continue;
61
+ }
62
+ for (const binding of bindings.elements) {
63
+ if (!binding.isTypeOnly && (binding.propertyName ?? binding.name).text === "stringParam") {
64
+ return binding.name.text;
65
+ }
66
+ }
67
+ }
68
+ if (identifiers(source).has("stringParam")) {
69
+ const name = unusedBinding(source, "filesystemStringParam");
70
+ imports.push(`import { stringParam as ${name} } from "@loozo/angular-router";`);
71
+ return name;
72
+ }
73
+ imports.push('import { stringParam } from "@loozo/angular-router";');
74
+ return "stringParam";
75
+ }
76
+ // After the last import, keeping ordinary same-line comments with the import they annotate. A
77
+ // directive or multiline comment there can bind to the next line, so insert before it instead.
78
+ function importPosition(source) {
79
+ let last;
80
+ for (const statement of source.statements) {
81
+ if (ts.isImportDeclaration(statement)) {
82
+ last = statement;
83
+ }
84
+ }
85
+ // Every admitted declaration imports defineRoute.
86
+ let position = last.end;
87
+ for (const range of ts.getTrailingCommentRanges(source.text, last.end) ?? []) {
88
+ const comment = source.text.slice(range.pos, range.end);
89
+ if (directive.test(comment) || /[\r\n]/.test(comment)) {
90
+ return last.end;
91
+ }
92
+ position = range.end;
93
+ }
94
+ return position;
95
+ }
96
+ // Point the parent property at a binding, as shorthand when it is named `parent` and nothing
97
+ // but whitespace separates the key from its value.
98
+ function parentValue(property, value, name, source) {
99
+ if (ts.isShorthandPropertyAssignment(property)) {
100
+ return { start: value.getStart(source), end: value.end, text: `parent: ${name}` };
101
+ }
102
+ const separator = source.text.slice(property.name.end, value.getStart(source));
103
+ if (name === "parent" && /^\s*:\s*$/.test(separator)) {
104
+ return { start: property.getStart(source), end: property.end, text: name };
105
+ }
106
+ return { start: value.getStart(source), end: value.end, text: name };
107
+ }
33
108
  function removeSyntax(node, source) {
34
109
  const start = node.getStart(source);
35
110
  const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source.text);
@@ -46,8 +121,7 @@ function removeSyntax(node, source) {
46
121
  }
47
122
  // Include adjacent retained trivia: it too can become module-leading after
48
123
  // deletion. Stop at the next actual token, without copying trailing bytes.
49
- if (comment &&
50
- /@ts-(?:expect-error|ignore|check|nocheck)\b|@jsx(?:frag|importsource|runtime)?\b|\/\/\/\s*<(?:reference|amd-dependency|amd-module)\b|(?:eslint|oxlint)-disable-next-line\b/i.test(scanner.getTokenText())) {
124
+ if (comment && directive.test(scanner.getTokenText())) {
51
125
  fail(node, "Cannot remove directive-bound filesystem parent syntax; repair it explicitly");
52
126
  }
53
127
  if (scanner.getTokenPos() >= start && scanner.getTokenPos() < node.end && trivia) {
@@ -123,6 +197,47 @@ function otherUses(source, imported, parent, checker) {
123
197
  visit(source);
124
198
  return used;
125
199
  }
200
+ // The builder owns the path, so the fix is in the params or the directories, never the path.
201
+ // `opaque` says why the builder can't read the params' keys.
202
+ function ambiguousParams(node, path, removed, added, opaque) {
203
+ const changes = [];
204
+ if (removed.length > 0) {
205
+ changes.push(`removed params: ${removed.join(", ")}`);
206
+ }
207
+ if (added.length > 0) {
208
+ changes.push(`added params: ${added.join(", ")}`);
209
+ }
210
+ let repair = "Edit this file's params to match";
211
+ if (!parsePath(path).some((segment) => segment.kind !== "static")) {
212
+ repair = "Remove this file's params";
213
+ }
214
+ else if (opaque) {
215
+ repair = `This file's params ${opaque}; write each one as a plain \`name: codec\` property to match`;
216
+ }
217
+ fail(node, `Ambiguous filesystem params: the directories give path "${path}" (${changes.join("; ")}). ` +
218
+ `${repair}, or move or rename the directory`);
219
+ }
220
+ // The keys of a direct inline params object, or why the builder can't read them.
221
+ function paramKeys(params) {
222
+ if (!ts.isObjectLiteralExpression(params)) {
223
+ return "are not an inline object";
224
+ }
225
+ const keys = new Set();
226
+ for (const property of params.properties) {
227
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
228
+ return "use a spread or accessor";
229
+ }
230
+ const name = property.name;
231
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) {
232
+ return "use a computed or numeric key";
233
+ }
234
+ if (keys.has(name.text)) {
235
+ return `repeat the key ${name.text}`;
236
+ }
237
+ keys.add(name.text);
238
+ }
239
+ return keys;
240
+ }
126
241
  function patchDeclaration(declaration, path, parentFile, options, host, checker) {
127
242
  const source = declaration.source;
128
243
  const assignment = source.statements.find(ts.isExportAssignment);
@@ -149,34 +264,85 @@ function patchDeclaration(declaration, path, parentFile, options, host, checker)
149
264
  desiredNames.add(segment.name);
150
265
  }
151
266
  }
152
- const sameNames = names.size === desiredNames.size && [...names].every((name) => desiredNames.has(name));
153
- if (!sameNames) {
267
+ const removed = [];
268
+ const added = [];
269
+ for (const name of names) {
270
+ if (!desiredNames.has(name)) {
271
+ removed.push(name);
272
+ }
273
+ }
274
+ for (const name of desiredNames) {
275
+ if (!names.has(name)) {
276
+ added.push(name);
277
+ }
278
+ }
279
+ if (removed.length > 0 || added.length > 0) {
154
280
  const params = declaration.config.get("params");
155
- if (names.size === 1 && desiredNames.size === 1 && params) {
156
- const entries = properties(params, "Filesystem local params");
157
- const oldName = [...names][0];
158
- const newName = [...desiredNames][0];
159
- if (entries.size !== 1 || !entries.has(oldName)) {
160
- fail(params, "Ambiguous filesystem codec change; update local params explicitly");
161
- }
162
- const inline = params;
163
- const property = inline.properties[0];
164
- if (!ts.isPropertyAssignment(property)) {
165
- fail(property, "Filesystem param rename requires a direct property with its codec expression");
281
+ let keys = new Set();
282
+ if (params) {
283
+ const readable = paramKeys(params);
284
+ if (typeof readable === "string") {
285
+ ambiguousParams(params, path, removed, added, readable);
166
286
  }
167
- patches.push({
168
- start: property.name.getStart(source),
169
- end: property.name.end,
170
- text: JSON.stringify(newName),
171
- });
287
+ keys = readable;
172
288
  }
173
- else if (names.size === 0 && desiredNames.size === 1 && !params) {
174
- const name = unusedBinding(source, "filesystemStringParam");
175
- imports.push(`import { stringParam as ${name} } from "@loozo/angular-router";${newline}`);
176
- additions.push(`params: { ${JSON.stringify([...desiredNames][0])}: ${name} }`);
289
+ // Params the author already made match the directories need only the path rewritten. A
290
+ // path without params matches only a declaration without `params`, as at runtime.
291
+ let reconciled = keys.size === desiredNames.size;
292
+ if (desiredNames.size === 0 && params) {
293
+ reconciled = false;
177
294
  }
178
- else {
179
- fail(literal, "Ambiguous filesystem codec change; update path and local params explicitly before synchronization");
295
+ for (const name of desiredNames) {
296
+ if (!keys.has(name)) {
297
+ reconciled = false;
298
+ }
299
+ }
300
+ if (!reconciled) {
301
+ if (removed.length === 1 && added.length === 1 && params) {
302
+ const entries = properties(params, "Filesystem local params");
303
+ const [oldName] = removed;
304
+ const [newName] = added;
305
+ if (entries.size !== names.size || !entries.has(oldName)) {
306
+ ambiguousParams(params, path, removed, added);
307
+ }
308
+ const property = entries.get(oldName).parent;
309
+ if (!ts.isPropertyAssignment(property)) {
310
+ ambiguousParams(property, path, removed, added, "use a shorthand property");
311
+ }
312
+ patches.push({
313
+ start: property.name.getStart(source),
314
+ end: property.name.end,
315
+ text: JSON.stringify(newName),
316
+ });
317
+ }
318
+ else if (removed.length === 0 && params) {
319
+ // Pure additions keep every authored codec and give each new param the seed's codec.
320
+ for (const name of names) {
321
+ if (!keys.has(name)) {
322
+ ambiguousParams(params, path, removed, added);
323
+ }
324
+ }
325
+ if (keys.size !== names.size) {
326
+ ambiguousParams(params, path, removed, added);
327
+ }
328
+ const name = stringParamBinding(source, imports);
329
+ const appended = [];
330
+ for (const param of added) {
331
+ appended.push(`${JSON.stringify(param)}: ${name}`);
332
+ }
333
+ patches.push(addProperty(params, appended.join(", ")));
334
+ }
335
+ else if (names.size === 0 && !params) {
336
+ const name = stringParamBinding(source, imports);
337
+ const entries = [];
338
+ for (const param of added) {
339
+ entries.push(`${JSON.stringify(param)}: ${name}`);
340
+ }
341
+ additions.push(`params: { ${entries.join(", ")} }`);
342
+ }
343
+ else {
344
+ ambiguousParams(literal, path, removed, added);
345
+ }
180
346
  }
181
347
  }
182
348
  patches.push({ start: literal.getStart(source), end: literal.end, text: JSON.stringify(path) });
@@ -195,6 +361,13 @@ function patchDeclaration(declaration, path, parentFile, options, host, checker)
195
361
  end: module.end,
196
362
  text: JSON.stringify(specifier(declaration.file, parentFile)),
197
363
  });
364
+ // A specifier that still resolves names a different parent, and so does its binding. An
365
+ // unresolvable one went stale because a file moved or was deleted; its name may still fit.
366
+ const binding = imported.importClause.name;
367
+ if (resolved && binding.text !== "parent" && !identifiers(source).has("parent")) {
368
+ patches.push({ start: binding.getStart(source), end: binding.end, text: "parent" });
369
+ patches.push(parentValue(parent.parent, parent, "parent", source));
370
+ }
198
371
  }
199
372
  else {
200
373
  if (!resolved && (usedElsewhere || imported.importClause?.namedBindings)) {
@@ -210,35 +383,35 @@ function patchDeclaration(declaration, path, parentFile, options, host, checker)
210
383
  patches.push(...removeProperty(property, source));
211
384
  }
212
385
  else {
213
- parentName = unusedBinding(source, "filesystemParent");
214
- let text = parentName;
215
- if (ts.isShorthandPropertyAssignment(property)) {
216
- text = `parent: ${parentName}`;
217
- }
218
- patches.push({ start: parent.getStart(source), end: parent.end, text });
386
+ parentName = parentBinding(source);
387
+ patches.push(parentValue(property, parent, parentName, source));
219
388
  }
220
389
  }
221
390
  }
222
391
  }
223
392
  else if (parentFile) {
224
- parentName = unusedBinding(source, "filesystemParent");
225
- additions.push(`parent: ${parentName}`);
393
+ parentName = parentBinding(source);
394
+ if (parentName === "parent") {
395
+ additions.push("parent");
396
+ }
397
+ else {
398
+ additions.push(`parent: ${parentName}`);
399
+ }
226
400
  }
227
401
  if (parentFile && parentName) {
228
402
  // A new binding leaves a still-valid old import and its unrelated uses alone.
229
- imports.push(`import ${parentName} from ${JSON.stringify(specifier(declaration.file, parentFile))};${newline}`);
403
+ imports.push(`import ${parentName} from ${JSON.stringify(specifier(declaration.file, parentFile))};`);
230
404
  }
231
405
  if (additions.length > 0) {
232
406
  patches.push(addProperty(object, additions.join(", ")));
233
407
  }
234
408
  if (imports.length > 0) {
235
- // Static imports are hoisted. Appending at the complete module boundary
236
- // preserves every existing statement/comment attachment without parsing it.
237
- patches.push({
238
- start: source.text.length,
239
- end: source.text.length,
240
- text: newline + imports.join(""),
241
- });
409
+ const position = importPosition(source);
410
+ let text = "";
411
+ for (const statement of imports) {
412
+ text += newline + statement;
413
+ }
414
+ patches.push({ start: position, end: position, text });
242
415
  }
243
416
  patches.sort((a, b) => b.start - a.start);
244
417
  let bytes = source.text;
@@ -254,6 +427,8 @@ function patchDeclaration(declaration, path, parentFile, options, host, checker)
254
427
  }
255
428
  export async function synchronize(root, declarations, pages, options, { filename = "route.ts" }, checker) {
256
429
  const byDirectory = new Map();
430
+ // Only directories with a page or a declaration become records. Others contribute their
431
+ // path segment to the records below them.
257
432
  const participating = new Set();
258
433
  for (const declaration of declarations) {
259
434
  const directory = dirname(declaration.file);
@@ -277,15 +452,6 @@ export async function synchronize(root, declarations, pages, options, { filename
277
452
  }
278
453
  participating.add(directory);
279
454
  }
280
- for (const directory of participating) {
281
- let ancestor = directory;
282
- while (ancestor !== root) {
283
- ancestor = dirname(ancestor);
284
- if (!transparent(root, ancestor)) {
285
- participating.add(ancestor);
286
- }
287
- }
288
- }
289
455
  const files = new Map();
290
456
  for (const directory of [...participating].sort()) {
291
457
  let file = byDirectory.get(directory)?.file;
@@ -305,16 +471,30 @@ export async function synchronize(root, declarations, pages, options, { filename
305
471
  };
306
472
  const authored = new Map();
307
473
  for (const [directory, file] of files) {
474
+ const contributions = [contribution(root, directory)];
475
+ let ancestor = directory;
308
476
  let parentFile;
309
- if (directory !== root) {
310
- let parent = dirname(directory);
311
- while (transparent(root, parent)) {
312
- parent = dirname(parent);
477
+ while (ancestor !== root && !parentFile) {
478
+ ancestor = dirname(ancestor);
479
+ parentFile = files.get(ancestor);
480
+ if (!parentFile) {
481
+ contributions.unshift(contribution(root, ancestor));
313
482
  }
314
- parentFile = files.get(parent);
315
483
  }
316
- const path = localPath(root, directory);
317
- const segments = parsePath(path);
484
+ const parts = [];
485
+ for (const part of contributions) {
486
+ if (part !== "") {
487
+ parts.push(part);
488
+ }
489
+ }
490
+ const path = parts.join("/");
491
+ let segments;
492
+ try {
493
+ segments = parsePath(path);
494
+ }
495
+ catch (error) {
496
+ throw new Error(`Invalid filesystem path "${path}" for ${file}: ${error.message}`);
497
+ }
318
498
  const declaration = byDirectory.get(directory);
319
499
  if (declaration) {
320
500
  const bytes = patchDeclaration(declaration, path, parentFile, options, host, checker);
@@ -330,14 +510,26 @@ export async function synchronize(root, declarations, pages, options, { filename
330
510
  imports.push(`import parent from ${JSON.stringify(specifier(file, parentFile))};`);
331
511
  config.push("parent");
332
512
  }
513
+ const params = new Set();
333
514
  for (const segment of segments) {
334
515
  if (segment.kind !== "static") {
335
- imported.push("stringParam");
336
- config.push(`params: { ${JSON.stringify(segment.name)}: stringParam }`);
516
+ params.add(segment.name);
517
+ }
518
+ }
519
+ if (params.size > 0) {
520
+ const entries = [];
521
+ for (const name of params) {
522
+ entries.push(`${JSON.stringify(name)}: stringParam`);
337
523
  }
524
+ imported.push("stringParam");
525
+ config.push(`params: { ${entries.join(", ")} }`);
526
+ }
527
+ let object = "{}";
528
+ if (config.length > 0) {
529
+ object = `{ ${config.join(", ")} }`;
338
530
  }
339
531
  imports.unshift(`import { ${imported.join(", ")} } from "@loozo/angular-router";`);
340
- authored.set(file, `${imports.join("\n")}\n\nexport default defineRoute(${JSON.stringify(path)}, { ${config.join(", ")} });\n`);
532
+ authored.set(file, `${imports.join("\n")}\n\nexport default defineRoute(${JSON.stringify(path)}, ${object});\n`);
341
533
  }
342
534
  return authored;
343
535
  }
@@ -6,7 +6,7 @@ export interface GenerationOptions {
6
6
  scanDirectories: string[];
7
7
  outDirectory: string;
8
8
  check?: boolean;
9
- autoDefineRoutes?: AutoDefineRoutes;
9
+ autoDefineRoutes?: AutoDefineRoutes | false;
10
10
  }
11
11
  export interface GenerationState {
12
12
  discovery: CompilerState;
@@ -4,7 +4,12 @@ import { createValidationProgram, selections, validateRoutes } from "./validate.
4
4
  import { emit } from "./emit.js";
5
5
  import { auditPath, metadata, planOutputs, publish, walk } from "./files.js";
6
6
  export async function generate(workspace, options, stopped, state) {
7
- const sources = await discover(workspace, options.tsconfig, options.scanDirectories, options.outDirectory, options.autoDefineRoutes, undefined, undefined, undefined, state?.discovery);
7
+ const { autoDefineRoutes = {} } = options;
8
+ let auto;
9
+ if (autoDefineRoutes !== false) {
10
+ auto = autoDefineRoutes;
11
+ }
12
+ const sources = await discover(workspace, options.tsconfig, options.scanDirectories, options.outDirectory, auto, undefined, undefined, undefined, state?.discovery);
8
13
  const selected = selections(sources);
9
14
  const desired = emit(sources, selected);
10
15
  const globalFiles = [
@@ -167,6 +167,7 @@ const keys = new Set([
167
167
  "canActivate",
168
168
  "canActivateChild",
169
169
  "canMatch",
170
+ "onInvalidParams",
170
171
  "canDeactivate",
171
172
  "providers",
172
173
  "title",
@@ -354,6 +355,9 @@ export async function discover(workspace, config, scans, out, auto, authored = n
354
355
  if (!auto && !ignored && needsParams !== config.has("params")) {
355
356
  fail(configNode, "Local params must be present exactly when the path declares placeholders");
356
357
  }
358
+ if (!auto && !ignored && !needsParams && config.has("onInvalidParams")) {
359
+ fail(configNode, "onInvalidParams requires local path placeholders");
360
+ }
357
361
  const data = config.get("data");
358
362
  if (data && ts.isObjectLiteralExpression(data)) {
359
363
  for (const property of data.properties) {
@@ -470,8 +474,9 @@ export async function discover(workspace, config, scans, out, auto, authored = n
470
474
  if (declaration.page ||
471
475
  declaration.children.length ||
472
476
  declaration.config.has("canActivate") ||
473
- declaration.config.has("canMatch")) {
474
- fail(redirect, "Redirect cannot have a component, children, canActivate or canMatch");
477
+ declaration.config.has("canMatch") ||
478
+ declaration.config.has("onInvalidParams")) {
479
+ fail(redirect, "Redirect cannot have a component, children, canActivate, canMatch or onInvalidParams");
475
480
  }
476
481
  declaration.retained = false;
477
482
  }
@@ -375,7 +375,7 @@ export async function validateRoutes(sources, generated, validation, canonical)
375
375
  fail(redirect, `Incompatible logical redirect params: ${sourceKey} -> ${targetKey.name}`);
376
376
  }
377
377
  }
378
- const sourceSearch = typeWitness(declaration, "__search");
378
+ // Every search key is optional when writing a URL, so only replacement values need checking.
379
379
  const targetSearch = typeWitness(declaration.target, "__search");
380
380
  const search = fields.get("search");
381
381
  const fragment = fields.get("fragment");
@@ -420,22 +420,6 @@ export async function validateRoutes(sources, generated, validation, canonical)
420
420
  fail(value, "Redirect replacement fragment is not declared by its destination");
421
421
  }
422
422
  }
423
- const required = targetSearch
424
- .getProperties()
425
- .filter((property) => !(property.flags & ts.SymbolFlags.Optional));
426
- if (mode.text === "drop" && required.length > 0) {
427
- fail(search, `Cannot drop required destination search: ${required.map((property) => property.name).join(", ")}`);
428
- }
429
- if (mode.text === "preserve") {
430
- for (const key of required) {
431
- const sourceKey = sourceSearch.getProperty(key.name);
432
- if (!sourceKey ||
433
- sourceKey.flags & ts.SymbolFlags.Optional ||
434
- !checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(sourceKey, search), checker.getTypeOfSymbolAtLocation(key, search))) {
435
- fail(search, `Source cannot guarantee required destination search: ${key.name}`);
436
- }
437
- }
438
- }
439
423
  if (mode.text === "replace") {
440
424
  const values = policy.get("values");
441
425
  if (!values) {
@@ -444,15 +428,22 @@ export async function validateRoutes(sources, generated, validation, canonical)
444
428
  const type = checker.getTypeAtLocation(values);
445
429
  for (const key of type.getProperties()) {
446
430
  const targetKey = targetSearch.getProperty(key.name);
447
- if (!targetKey ||
448
- !checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(key, values), checker.getTypeOfSymbolAtLocation(targetKey, values))) {
431
+ if (!targetKey) {
449
432
  fail(values, `Unknown/incompatible redirect replacement key: ${key.name}`);
450
433
  }
451
- }
452
- for (const key of required) {
453
- const supplied = type.getProperty(key.name);
454
- if (!supplied || supplied.flags & ts.SymbolFlags.Optional) {
455
- fail(values, `Missing required redirect replacement: ${key.name}`);
434
+ // Replacements use the write type, `Value | undefined`: undefined omits the key, even
435
+ // one that reads a fallback, so only the other constituents must be codec values.
436
+ const supplied = checker.getTypeOfSymbolAtLocation(key, values);
437
+ const target = checker.getTypeOfSymbolAtLocation(targetKey, values);
438
+ let constituents = [supplied];
439
+ if (supplied.isUnion()) {
440
+ constituents = supplied.types;
441
+ }
442
+ for (const constituent of constituents) {
443
+ if (!(constituent.flags & ts.TypeFlags.Undefined) &&
444
+ !checker.isTypeAssignableTo(constituent, target)) {
445
+ fail(values, `Unknown/incompatible redirect replacement key: ${key.name}`);
446
+ }
456
447
  }
457
448
  }
458
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loozo/angular-router",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Typed route declarations, URLs, navigation and route handles for Angular's native router.",
5
5
  "bugs": "https://github.com/theo-matzavinos/loozo/issues",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema",
3
- "$comment": "Generated by scripts/application-schema.mjs from the @angular/build 22.2.2 application schema plus the router options. Do not edit. The @angular/build schema portions are distributed under this license:\n\nThe MIT License\n\nCopyright (c) 2010-2026 Google LLC. https://angular.dev/license\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.",
3
+ "$comment": "Generated by scripts/builder-schemas.mjs from the @angular/build 22.2.2 application schema plus the router options. Do not edit. The @angular/build schema portions are distributed under this license:\n\nThe MIT License\n\nCopyright (c) 2010-2026 Google LLC. https://angular.dev/license\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.",
4
4
  "title": "Application schema for Build Facade.",
5
5
  "description": "Application builder target options",
6
6
  "type": "object",
@@ -19,28 +19,37 @@
19
19
  }
20
20
  },
21
21
  "autoDefineRoutes": {
22
- "$comment": "Conditional validation prevents Angular addUndefinedDefaults from creating an omitted opt-in object.",
23
- "if": true,
24
- "then": {
25
- "type": "object",
26
- "additionalProperties": false,
27
- "properties": {
28
- "filename": {
29
- "type": "string",
30
- "pattern": "^[A-Za-z0-9_$][A-Za-z0-9_$.-]*\\.ts$",
31
- "not": {
32
- "anyOf": [
33
- {
34
- "pattern": "\\.d\\.ts$"
35
- },
36
- {
37
- "enum": ["route.generated.ts", "routes.generated.ts", "destinations.generated.ts"]
38
- }
39
- ]
22
+ "$comment": "Omitted enables filesystem route definitions with route.ts; false selects explicit authoring.",
23
+ "anyOf": [
24
+ {
25
+ "type": "boolean",
26
+ "const": false
27
+ },
28
+ {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "filename": {
33
+ "type": "string",
34
+ "pattern": "^[A-Za-z0-9_$][A-Za-z0-9_$.-]*\\.ts$",
35
+ "not": {
36
+ "anyOf": [
37
+ {
38
+ "pattern": "\\.d\\.ts$"
39
+ },
40
+ {
41
+ "enum": [
42
+ "route.generated.ts",
43
+ "routes.generated.ts",
44
+ "destinations.generated.ts"
45
+ ]
46
+ }
47
+ ]
48
+ }
40
49
  }
41
50
  }
42
51
  }
43
- }
52
+ ]
44
53
  },
45
54
  "outDirectory": {
46
55
  "type": "string",