@loozo/angular-router 0.0.2 → 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 (68) hide show
  1. package/README.md +565 -80
  2. package/builders.json +2 -2
  3. package/fesm2022/loozo-angular-router-_core.mjs +332 -595
  4. package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
  5. package/fesm2022/loozo-angular-router-_generated.mjs +335 -627
  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 +19 -7
  10. package/node/src/builders/options.js +47 -61
  11. package/node/src/builders/serve.js +66 -16
  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/emit.js +26 -8
  18. package/node/src/generation/files.d.ts +26 -0
  19. package/node/src/generation/files.js +14 -3
  20. package/node/src/generation/filesystem.d.ts +7 -0
  21. package/node/src/generation/filesystem.js +261 -69
  22. package/node/src/generation/generate.d.ts +16 -0
  23. package/node/src/generation/generate.js +10 -4
  24. package/node/src/generation/index.d.ts +16 -0
  25. package/node/src/generation/index.js +15 -0
  26. package/node/src/generation/overlay.d.ts +3 -0
  27. package/node/src/generation/selectors.d.ts +2 -0
  28. package/node/src/generation/sources.d.ts +50 -0
  29. package/node/src/generation/sources.js +57 -12
  30. package/node/src/generation/validate.d.ts +12 -0
  31. package/node/src/generation/validate.js +53 -57
  32. package/package.json +7 -2
  33. package/schemas/application.json +764 -20
  34. package/schemas/serve.json +144 -30
  35. package/snippets/angular.json +29 -0
  36. package/snippets/generated/destinations.generated.ts +126 -0
  37. package/snippets/generated/routes.generated.ts +35 -0
  38. package/snippets/src/catalog.ts +20 -0
  39. package/snippets/src/docs-url.ts +8 -0
  40. package/snippets/src/index.html +11 -0
  41. package/snippets/src/main.ts +6 -0
  42. package/snippets/src/product-url.ts +8 -0
  43. package/snippets/src/routes/products/$productId/page.ts +20 -0
  44. package/snippets/src/routes/products/$productId/route.generated.ts +24 -0
  45. package/snippets/src/routes/products/$productId/route.ts +7 -0
  46. package/snippets/src/routes/reports/$reportId/page.ts +9 -0
  47. package/snippets/src/routes/reports/$reportId/route.generated.ts +24 -0
  48. package/snippets/src/routes/reports/$reportId/route.ts +6 -0
  49. package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +15 -0
  50. package/snippets/src/routes/teams/$teamId/items/$id/links.ts +13 -0
  51. package/snippets/src/routes/teams/$teamId/items/$id/page.ts +9 -0
  52. package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +24 -0
  53. package/snippets/src/routes/teams/$teamId/items/$id/route.ts +18 -0
  54. package/snippets/src/routes/teams/$teamId/members/$memberId/page.ts +9 -0
  55. package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +24 -0
  56. package/snippets/src/routes/teams/$teamId/members/$memberId/route.ts +7 -0
  57. package/snippets/src/routes/teams/$teamId/old-items/$id/route.ts +13 -0
  58. package/snippets/src/routes/teams/$teamId/route.generated.ts +24 -0
  59. package/snippets/src/routes/teams/$teamId/route.ts +6 -0
  60. package/snippets/src/shell.ts +9 -0
  61. package/snippets/src/slug.ts +14 -0
  62. package/snippets/tsconfig.app.json +14 -0
  63. package/snippets/tsconfig.routes.json +5 -0
  64. package/types/loozo-angular-router-_core.d.ts +262 -242
  65. package/types/loozo-angular-router-_generated.d.ts +155 -116
  66. package/types/loozo-angular-router.d.ts +2 -1
  67. package/.npmignore +0 -3
  68. package/fesm2022/loozo-angular-router.mjs.map +0 -1
@@ -41,7 +41,7 @@ export function properties(node, label) {
41
41
  export function resolveModule(name, source, options, host = ts.sys) {
42
42
  return ts.resolveModuleName(name, source, options, host).resolvedModule?.resolvedFileName;
43
43
  }
44
- function namedImport(source, name, imported) {
44
+ export function namedImport(source, name, imported) {
45
45
  if (!ts.isIdentifier(name)) {
46
46
  return undefined;
47
47
  }
@@ -90,7 +90,7 @@ function moduleText(statement) {
90
90
  }
91
91
  return statement.moduleSpecifier.text;
92
92
  }
93
- async function genuineImport(statement, expected, options, host = ts.sys) {
93
+ export async function genuineImport(statement, expected, options, host = ts.sys) {
94
94
  const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options, host);
95
95
  if (!actual) {
96
96
  return false;
@@ -167,12 +167,13 @@ const keys = new Set([
167
167
  "canActivate",
168
168
  "canActivateChild",
169
169
  "canMatch",
170
+ "onInvalidParams",
170
171
  "canDeactivate",
171
172
  "providers",
172
173
  "title",
173
174
  "data",
174
175
  ]);
175
- export async function discover(workspace, config, scans, out, auto, authored = new Map(), filesystem = auto !== undefined) {
176
+ export async function discover(workspace, config, scans, out, auto, authored = new Map(), filesystem = auto !== undefined, launch, compiler) {
176
177
  if (scans.length !== 1) {
177
178
  throw new Error("scanDirectories must contain exactly one directory");
178
179
  }
@@ -223,7 +224,7 @@ export async function discover(workspace, config, scans, out, auto, authored = n
223
224
  continue;
224
225
  }
225
226
  await auditPath(normalized);
226
- if (contains(root, normalized)) {
227
+ if (launch || contains(root, normalized)) {
227
228
  candidates.push(normalized);
228
229
  }
229
230
  }
@@ -233,14 +234,22 @@ export async function discover(workspace, config, scans, out, auto, authored = n
233
234
  const manifest = JSON.parse(manifestBytes);
234
235
  const factoryEntry = await realpath(resolve(packageRoot, manifest.exports["."].types));
235
236
  const declarations = [];
237
+ const launchDeclarations = [];
238
+ const launchEntry = launch && (await realpath(launch.typeEntry));
236
239
  const pages = new Map();
237
240
  const deferredFactories = [];
238
241
  const syntaxHost = overlayHost(parsed.options, authored);
239
- const syntaxProgram = ts.createProgram({
240
- rootNames: candidates,
241
- options: parsed.options,
242
- host: syntaxHost,
243
- });
242
+ let syntaxProgram;
243
+ if (compiler) {
244
+ syntaxProgram = compiler.createProgram(candidates, parsed.options, syntaxHost);
245
+ }
246
+ else {
247
+ syntaxProgram = ts.createProgram({
248
+ rootNames: candidates,
249
+ options: parsed.options,
250
+ host: syntaxHost,
251
+ });
252
+ }
244
253
  for (const file of candidates) {
245
254
  const bytes = syntaxHost.readFile(file);
246
255
  if (bytes === undefined) {
@@ -281,6 +290,33 @@ export async function discover(workspace, config, scans, out, auto, authored = n
281
290
  fail(defaults[1], "Multiple competing default exports");
282
291
  }
283
292
  const node = defaults[0];
293
+ const inRoutes = contains(root, file);
294
+ if (launchEntry && ts.isExportAssignment(node) && ts.isCallExpression(node.expression)) {
295
+ const call = node.expression;
296
+ let kind;
297
+ let imported = namedImport(source, call.expression, "defineContext");
298
+ if (imported) {
299
+ kind = "defineContext";
300
+ }
301
+ else if (inRoutes) {
302
+ imported = namedImport(source, call.expression, "defineRpc");
303
+ if (imported) {
304
+ kind = "defineRpc";
305
+ }
306
+ }
307
+ if (kind && imported) {
308
+ if (!(await genuineImport(imported, launchEntry, parsed.options, syntaxHost))) {
309
+ fail(imported, `${kind} must resolve to Launch's concrete server entry`);
310
+ }
311
+ launchDeclarations.push({ file, kind, call });
312
+ continue;
313
+ }
314
+ }
315
+ // Context is audited over all application roots; unrelated bootstrap defaults
316
+ // outside route scans are not route declarations.
317
+ if (!inRoutes) {
318
+ continue;
319
+ }
284
320
  if (ts.isClassDeclaration(node)) {
285
321
  await component(source, node, parsed.options);
286
322
  if (pages.has(dirname(file))) {
@@ -319,6 +355,9 @@ export async function discover(workspace, config, scans, out, auto, authored = n
319
355
  if (!auto && !ignored && needsParams !== config.has("params")) {
320
356
  fail(configNode, "Local params must be present exactly when the path declares placeholders");
321
357
  }
358
+ if (!auto && !ignored && !needsParams && config.has("onInvalidParams")) {
359
+ fail(configNode, "onInvalidParams requires local path placeholders");
360
+ }
322
361
  const data = config.get("data");
323
362
  if (data && ts.isObjectLiteralExpression(data)) {
324
363
  for (const property of data.properties) {
@@ -363,7 +402,7 @@ export async function discover(workspace, config, scans, out, auto, authored = n
363
402
  }
364
403
  if (auto) {
365
404
  const edits = await synchronize(roots[0], declarations, pages, parsed.options, auto, syntaxProgram.getTypeChecker());
366
- return discover(workspace, config, scans, out, undefined, edits, true);
405
+ return discover(workspace, config, scans, out, undefined, edits, true, launch, compiler);
367
406
  }
368
407
  if (filesystem) {
369
408
  for (let index = declarations.length - 1; index >= 0; index -= 1) {
@@ -435,8 +474,9 @@ export async function discover(workspace, config, scans, out, auto, authored = n
435
474
  if (declaration.page ||
436
475
  declaration.children.length ||
437
476
  declaration.config.has("canActivate") ||
438
- declaration.config.has("canMatch")) {
439
- 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");
440
480
  }
441
481
  declaration.retained = false;
442
482
  }
@@ -519,6 +559,10 @@ export async function discover(workspace, config, scans, out, auto, authored = n
519
559
  fail(declaration.source, "Factory facade is not a final retained generated location");
520
560
  }
521
561
  }
562
+ let launchSources;
563
+ if (launchEntry) {
564
+ launchSources = { typeEntry: launchEntry, declarations: launchDeclarations };
565
+ }
522
566
  return {
523
567
  configFile,
524
568
  parsed,
@@ -530,5 +574,6 @@ export async function discover(workspace, config, scans, out, auto, authored = n
530
574
  deferredFactories,
531
575
  authored,
532
576
  filesystem,
577
+ launch: launchSources,
533
578
  };
534
579
  }
@@ -0,0 +1,12 @@
1
+ import ts from "typescript";
2
+ import { type Declaration, type Sources } from "./sources.js";
3
+ import type { CompilerState } from "./compiler.js";
4
+ import { AnchorState } from "./anchor.js";
5
+ export declare function authoredPath(declaration: Declaration): string;
6
+ export declare function selections(sources: Sources): Map<string, Declaration>;
7
+ export declare function createValidationProgram(sources: Sources, generated: ReadonlyMap<string, string>, stale: readonly string[], compiler?: CompilerState): {
8
+ program: ts.Program;
9
+ host: ts.CompilerHost;
10
+ obsolete: Set<string>;
11
+ };
12
+ export declare function validateRoutes(sources: Sources, generated: ReadonlyMap<string, string>, validation: ReturnType<typeof createValidationProgram>, canonical?: AnchorState): Promise<void>;
@@ -4,6 +4,7 @@ import { basename, dirname, resolve, sep } from "node:path";
4
4
  import { auditPath, contains } from "./files.js";
5
5
  import { fail, properties, resolveModule } from "./sources.js";
6
6
  import { overlayHost } from "./overlay.js";
7
+ import { AnchorState } from "./anchor.js";
7
8
  export function authoredPath(declaration) {
8
9
  const parts = [];
9
10
  for (const segment of declaration.effective) {
@@ -111,9 +112,13 @@ export function selections(sources) {
111
112
  }
112
113
  return result;
113
114
  }
114
- // Overlay only the final generated locations. Never evaluate application code.
115
- export async function validateProgram(sources, generated, stale) {
116
- const host = overlayHost(sources.parsed.options, sources.authored);
115
+ // Overlay only final generated locations. Never evaluate application code.
116
+ export function createValidationProgram(sources, generated, stale, compiler) {
117
+ const options = { ...sources.parsed.options };
118
+ if (sources.launch) {
119
+ options.skipLibCheck = false;
120
+ }
121
+ const host = overlayHost(options, sources.authored);
117
122
  const originalRead = host.readFile.bind(host);
118
123
  const originalExists = host.fileExists.bind(host);
119
124
  const originalDirectoryExists = host.directoryExists.bind(host);
@@ -141,14 +146,24 @@ export async function validateProgram(sources, generated, stale) {
141
146
  }
142
147
  return ts.createSourceFile(file, text, language, true);
143
148
  };
144
- const program = ts.createProgram({
145
- rootNames: [
146
- ...sources.parsed.fileNames.filter((file) => !obsolete.has(resolve(file))),
147
- sources.factoryEntry,
148
- ],
149
- options: sources.parsed.options,
150
- host,
151
- });
149
+ const rootNames = [
150
+ ...sources.parsed.fileNames.filter((file) => !obsolete.has(resolve(file))),
151
+ sources.factoryEntry,
152
+ ];
153
+ if (sources.launch) {
154
+ rootNames.push(sources.launch.typeEntry, ...generated.keys());
155
+ }
156
+ let program;
157
+ if (compiler) {
158
+ program = compiler.createProgram(rootNames, options, host);
159
+ }
160
+ else {
161
+ program = ts.createProgram({ rootNames, options, host });
162
+ }
163
+ return { program, host, obsolete };
164
+ }
165
+ export async function validateRoutes(sources, generated, validation, canonical) {
166
+ const { program, host, obsolete } = validation;
152
167
  if (sources.filesystem) {
153
168
  const diagnostics = [];
154
169
  for (const declaration of sources.declarations) {
@@ -176,20 +191,8 @@ export async function validateProgram(sources, generated, stale) {
176
191
  // Anchor the public export's declaration independently of application paths,
177
192
  // which can also redirect imports inside the concrete public type entry.
178
193
  const canonicalOptions = { ...sources.parsed.options, paths: undefined, baseUrl: undefined };
179
- const canonicalProgram = ts.createProgram({
180
- rootNames: [sources.factoryEntry],
181
- options: canonicalOptions,
182
- host: ts.createCompilerHost(canonicalOptions, true),
183
- });
184
- const canonicalChecker = canonicalProgram.getTypeChecker();
185
- const canonicalEntry = canonicalProgram.getSourceFile(sources.factoryEntry);
186
- const canonicalModule = canonicalChecker.getSymbolAtLocation(canonicalEntry);
187
- let canonicalFactory = canonicalChecker
188
- .getExportsOfModule(canonicalModule)
189
- .find((symbol) => symbol.name === "defineRoute");
190
- if (canonicalFactory.flags & ts.SymbolFlags.Alias) {
191
- canonicalFactory = canonicalChecker.getAliasedSymbol(canonicalFactory);
192
- }
194
+ const anchor = await (canonical ?? new AnchorState()).anchor(sources.factoryEntry, ["defineRoute"], canonicalOptions);
195
+ const canonicalFactory = anchor.get("defineRoute");
193
196
  // Tentative own-facade admission is not authority for its re-exported binding.
194
197
  // Prove both the concrete module boundary and the final native alias identity.
195
198
  for (const declaration of sources.declarations) {
@@ -215,17 +218,14 @@ export async function validateProgram(sources, generated, stale) {
215
218
  }
216
219
  if (!actualFactory?.declarations?.length ||
217
220
  actualFactory !== concreteFactory ||
218
- actualFactory.declarations.length !== canonicalFactory.declarations?.length) {
221
+ actualFactory.declarations.length !== canonicalFactory?.length) {
219
222
  fail(call, "Factory binding does not resolve to the concrete defineRoute export");
220
223
  }
221
224
  for (let index = 0; index < actualFactory.declarations.length; index += 1) {
222
225
  const actual = actualFactory.declarations[index];
223
- const canonical = canonicalFactory.declarations[index];
224
- const [actualFile, canonicalFile] = await Promise.all([
225
- realpath(actual.getSourceFile().fileName),
226
- realpath(canonical.getSourceFile().fileName),
227
- ]);
228
- if (actualFile !== canonicalFile ||
226
+ const canonical = canonicalFactory[index];
227
+ const actualFile = await realpath(actual.getSourceFile().fileName);
228
+ if (actualFile !== canonical.file ||
229
229
  actual.kind !== canonical.kind ||
230
230
  actual.pos !== canonical.pos ||
231
231
  actual.end !== canonical.end) {
@@ -237,6 +237,10 @@ export async function validateProgram(sources, generated, stale) {
237
237
  resolve(sources.output, "routes.generated.ts"),
238
238
  resolve(sources.output, "destinations.generated.ts"),
239
239
  ]);
240
+ if (sources.launch) {
241
+ globalTargets.add(resolve(sources.output, "context.generated.d.ts"));
242
+ globalTargets.add(resolve(sources.output, "server.generated.ts"));
243
+ }
240
244
  for (const source of program.getSourceFiles()) {
241
245
  if (source.fileName.includes("/node_modules/") ||
242
246
  program.isSourceFileFromExternalLibrary(source) ||
@@ -292,7 +296,8 @@ export async function validateProgram(sources, generated, stale) {
292
296
  candidates.add(resolve(file));
293
297
  }
294
298
  for (const candidate of candidates) {
295
- const facadeTarget = basename(candidate) === "route.generated.ts" &&
299
+ const facadeTarget = (basename(candidate) === "route.generated.ts" ||
300
+ (sources.launch && basename(candidate) === "rpc.generated.ts")) &&
296
301
  !candidate.split(sep).includes("node_modules") &&
297
302
  sources.roots.some((root) => contains(root, candidate));
298
303
  if ((facadeTarget || globalTargets.has(candidate)) && !generated.has(candidate)) {
@@ -370,7 +375,7 @@ export async function validateProgram(sources, generated, stale) {
370
375
  fail(redirect, `Incompatible logical redirect params: ${sourceKey} -> ${targetKey.name}`);
371
376
  }
372
377
  }
373
- const sourceSearch = typeWitness(declaration, "__search");
378
+ // Every search key is optional when writing a URL, so only replacement values need checking.
374
379
  const targetSearch = typeWitness(declaration.target, "__search");
375
380
  const search = fields.get("search");
376
381
  const fragment = fields.get("fragment");
@@ -415,22 +420,6 @@ export async function validateProgram(sources, generated, stale) {
415
420
  fail(value, "Redirect replacement fragment is not declared by its destination");
416
421
  }
417
422
  }
418
- const required = targetSearch
419
- .getProperties()
420
- .filter((property) => !(property.flags & ts.SymbolFlags.Optional));
421
- if (mode.text === "drop" && required.length > 0) {
422
- fail(search, `Cannot drop required destination search: ${required.map((property) => property.name).join(", ")}`);
423
- }
424
- if (mode.text === "preserve") {
425
- for (const key of required) {
426
- const sourceKey = sourceSearch.getProperty(key.name);
427
- if (!sourceKey ||
428
- sourceKey.flags & ts.SymbolFlags.Optional ||
429
- !checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(sourceKey, search), checker.getTypeOfSymbolAtLocation(key, search))) {
430
- fail(search, `Source cannot guarantee required destination search: ${key.name}`);
431
- }
432
- }
433
- }
434
423
  if (mode.text === "replace") {
435
424
  const values = policy.get("values");
436
425
  if (!values) {
@@ -439,15 +428,22 @@ export async function validateProgram(sources, generated, stale) {
439
428
  const type = checker.getTypeAtLocation(values);
440
429
  for (const key of type.getProperties()) {
441
430
  const targetKey = targetSearch.getProperty(key.name);
442
- if (!targetKey ||
443
- !checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(key, values), checker.getTypeOfSymbolAtLocation(targetKey, values))) {
431
+ if (!targetKey) {
444
432
  fail(values, `Unknown/incompatible redirect replacement key: ${key.name}`);
445
433
  }
446
- }
447
- for (const key of required) {
448
- const supplied = type.getProperty(key.name);
449
- if (!supplied || supplied.flags & ts.SymbolFlags.Optional) {
450
- 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
+ }
451
447
  }
452
448
  }
453
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loozo/angular-router",
3
- "version": "0.0.2",
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",
@@ -24,6 +24,10 @@
24
24
  "types": "./types/loozo-angular-router-_generated.d.ts",
25
25
  "default": "./fesm2022/loozo-angular-router-_generated.mjs"
26
26
  },
27
+ "./_generation": {
28
+ "types": "./node/src/generation/index.d.ts",
29
+ "node": "./node/src/generation/index.js"
30
+ },
27
31
  "./package.json": {
28
32
  "default": "./package.json"
29
33
  }
@@ -33,13 +37,14 @@
33
37
  "registry": "https://registry.npmjs.org/"
34
38
  },
35
39
  "dependencies": {
36
- "@angular-devkit/architect": "0.2201.5",
40
+ "@angular-devkit/architect": "0.2202.2",
37
41
  "chokidar": "5.0.0",
38
42
  "rxjs": "7.8.2",
39
43
  "tslib": "2.8.1",
40
44
  "typescript": "6.0.3"
41
45
  },
42
46
  "peerDependencies": {
47
+ "@angular/build": "~22.2.0",
43
48
  "@angular/core": "^22.0.0",
44
49
  "@angular/router": "^22.0.0"
45
50
  },