@loozo/angular-router 0.0.1 → 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 (40) hide show
  1. package/README.md +455 -63
  2. package/builders.json +2 -2
  3. package/fesm2022/loozo-angular-router-_core.mjs +348 -521
  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 -6
  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 +58 -52
  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/emit.js +26 -1
  18. package/node/src/generation/files.d.ts +26 -0
  19. package/node/src/generation/files.js +42 -10
  20. package/node/src/generation/filesystem.d.ts +7 -0
  21. package/node/src/generation/filesystem.js +343 -0
  22. package/node/src/generation/generate.d.ts +16 -0
  23. package/node/src/generation/generate.js +18 -6
  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/overlay.js +84 -0
  28. package/node/src/generation/selectors.d.ts +2 -0
  29. package/node/src/generation/sources.d.ts +50 -0
  30. package/node/src/generation/sources.js +109 -28
  31. package/node/src/generation/validate.d.ts +12 -0
  32. package/node/src/generation/validate.js +53 -33
  33. package/package.json +7 -2
  34. package/schemas/application.json +765 -7
  35. package/schemas/serve.json +11 -9
  36. package/types/loozo-angular-router-_core.d.ts +401 -124
  37. package/types/loozo-angular-router-_generated.d.ts +181 -44
  38. package/types/loozo-angular-router.d.ts +2 -1
  39. package/.npmignore +0 -3
  40. package/fesm2022/loozo-angular-router.mjs.map +0 -1
@@ -4,6 +4,8 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parsePath } from "../../_core/src/path.js";
6
6
  import { auditPath, contains, metadata, walk, workspacePaths, markers } from "./files.js";
7
+ import { overlayDirectory, overlayHost } from "./overlay.js";
8
+ import { synchronize, transparent } from "./filesystem.js";
7
9
  export function fail(node, message) {
8
10
  const source = node.getSourceFile();
9
11
  const position = source.getLineAndCharacterOfPosition(node.getStart());
@@ -39,7 +41,7 @@ export function properties(node, label) {
39
41
  export function resolveModule(name, source, options, host = ts.sys) {
40
42
  return ts.resolveModuleName(name, source, options, host).resolvedModule?.resolvedFileName;
41
43
  }
42
- function namedImport(source, name, imported) {
44
+ export function namedImport(source, name, imported) {
43
45
  if (!ts.isIdentifier(name)) {
44
46
  return undefined;
45
47
  }
@@ -88,8 +90,8 @@ function moduleText(statement) {
88
90
  }
89
91
  return statement.moduleSpecifier.text;
90
92
  }
91
- async function genuineImport(statement, expected, options) {
92
- const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options);
93
+ export async function genuineImport(statement, expected, options, host = ts.sys) {
94
+ const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options, host);
93
95
  if (!actual) {
94
96
  return false;
95
97
  }
@@ -170,25 +172,25 @@ const keys = new Set([
170
172
  "title",
171
173
  "data",
172
174
  ]);
173
- export async function discover(workspace, config, scans, out) {
175
+ export async function discover(workspace, config, scans, out, auto, authored = new Map(), filesystem = auto !== undefined, launch, compiler) {
176
+ if (scans.length !== 1) {
177
+ throw new Error("scanDirectories must contain exactly one directory");
178
+ }
174
179
  const access = await workspacePaths(workspace);
175
- const configFile = await access.resolve(config);
180
+ const configFile = access.resolve(config);
176
181
  const configDirectory = dirname(configFile);
177
- const output = await access.resolve(out);
178
- const resolvedRoots = await Promise.all(scans.map((scan) => access.resolve(resolve(configDirectory, scan))));
179
- const roots = [...new Set(resolvedRoots)].sort();
180
- for (const root of roots) {
181
- if (contains(root, output) || contains(output, root)) {
182
- throw new Error(`Output and scan directory overlap: ${output}; ${root}`);
183
- }
182
+ const output = access.resolve(out);
183
+ const root = access.resolve(resolve(configDirectory, scans[0]));
184
+ const roots = [root];
185
+ if (contains(root, output) || contains(output, root)) {
186
+ throw new Error(`Output and scan directory overlap: ${output}; ${root}`);
184
187
  }
185
188
  await Promise.all([auditPath(configFile), auditPath(output)]);
186
- const encountered = [
187
- ...new Set((await Promise.all(roots.map((root) => walk(root)))).flat()),
188
- ].sort();
189
+ const encountered = (await walk(root)).sort();
189
190
  const fatal = [];
190
191
  const parsed = ts.getParsedCommandLineOfConfigFile(configFile, {}, {
191
192
  ...ts.sys,
193
+ readDirectory: overlayDirectory(authored, (directory) => access.resolve(directory, true)),
192
194
  onUnRecoverableConfigFileDiagnostic: (diagnostic) => fatal.push(diagnostic),
193
195
  });
194
196
  const errors = [...fatal, ...(parsed?.errors ?? [])].filter((error) => error.code !== 18003);
@@ -201,16 +203,27 @@ export async function discover(workspace, config, scans, out) {
201
203
  getNewLine: () => "\n",
202
204
  }));
203
205
  }
204
- const candidates = [];
206
+ // Canonicalize only permitted workspace-access prefixes, never source links.
207
+ // Membership, candidate discovery and the final program share these roots.
208
+ const admitted = new Set();
205
209
  for (const file of parsed.fileNames) {
206
- const normalized = await access.resolve(file);
210
+ admitted.add(access.resolve(file));
211
+ }
212
+ parsed.fileNames = [...admitted];
213
+ for (const file of authored.keys()) {
214
+ if (!admitted.has(file)) {
215
+ throw new Error(`TypeScript config does not admit filesystem definition: ${file}`);
216
+ }
217
+ }
218
+ const candidates = [];
219
+ for (const normalized of parsed.fileNames) {
207
220
  if (!normalized.endsWith(".ts") ||
208
221
  normalized.endsWith(".d.ts") ||
209
222
  normalized.split(sep).includes("node_modules")) {
210
223
  continue;
211
224
  }
212
225
  await auditPath(normalized);
213
- if (roots.some((root) => contains(root, normalized))) {
226
+ if (launch || contains(root, normalized)) {
214
227
  candidates.push(normalized);
215
228
  }
216
229
  }
@@ -220,15 +233,31 @@ export async function discover(workspace, config, scans, out) {
220
233
  const manifest = JSON.parse(manifestBytes);
221
234
  const factoryEntry = await realpath(resolve(packageRoot, manifest.exports["."].types));
222
235
  const declarations = [];
236
+ const launchDeclarations = [];
237
+ const launchEntry = launch && (await realpath(launch.typeEntry));
223
238
  const pages = new Map();
224
239
  const deferredFactories = [];
225
- const syntaxProgram = ts.createProgram({
226
- rootNames: candidates,
227
- options: parsed.options,
228
- host: ts.createCompilerHost(parsed.options, true),
229
- });
240
+ const syntaxHost = overlayHost(parsed.options, authored);
241
+ let syntaxProgram;
242
+ if (compiler) {
243
+ syntaxProgram = compiler.createProgram(candidates, parsed.options, syntaxHost);
244
+ }
245
+ else {
246
+ syntaxProgram = ts.createProgram({
247
+ rootNames: candidates,
248
+ options: parsed.options,
249
+ host: syntaxHost,
250
+ });
251
+ }
230
252
  for (const file of candidates) {
231
- const bytes = await readFile(file, "utf8");
253
+ const bytes = syntaxHost.readFile(file);
254
+ if (bytes === undefined) {
255
+ if (auto) {
256
+ // An explicit tsconfig files entry may name a not-yet-seeded definition.
257
+ continue;
258
+ }
259
+ throw new Error(`Cannot read admitted TypeScript source: ${file}`);
260
+ }
232
261
  if (file.endsWith(`${sep}route.generated.ts`) &&
233
262
  bytes.split("\n", 1)[0] === markers["route.generated.ts"]) {
234
263
  continue;
@@ -260,6 +289,33 @@ export async function discover(workspace, config, scans, out) {
260
289
  fail(defaults[1], "Multiple competing default exports");
261
290
  }
262
291
  const node = defaults[0];
292
+ const inRoutes = contains(root, file);
293
+ if (launchEntry && ts.isExportAssignment(node) && ts.isCallExpression(node.expression)) {
294
+ const call = node.expression;
295
+ let kind;
296
+ let imported = namedImport(source, call.expression, "defineContext");
297
+ if (imported) {
298
+ kind = "defineContext";
299
+ }
300
+ else if (inRoutes) {
301
+ imported = namedImport(source, call.expression, "defineRpc");
302
+ if (imported) {
303
+ kind = "defineRpc";
304
+ }
305
+ }
306
+ if (kind && imported) {
307
+ if (!(await genuineImport(imported, launchEntry, parsed.options, syntaxHost))) {
308
+ fail(imported, `${kind} must resolve to Launch's concrete server entry`);
309
+ }
310
+ launchDeclarations.push({ file, kind, call });
311
+ continue;
312
+ }
313
+ }
314
+ // Context is audited over all application roots; unrelated bootstrap defaults
315
+ // outside route scans are not route declarations.
316
+ if (!inRoutes) {
317
+ continue;
318
+ }
263
319
  if (ts.isClassDeclaration(node)) {
264
320
  await component(source, node, parsed.options);
265
321
  if (pages.has(dirname(file))) {
@@ -294,7 +350,8 @@ export async function discover(workspace, config, scans, out) {
294
350
  fail(path, String(error));
295
351
  }
296
352
  const needsParams = segments.some((segment) => segment.kind !== "static");
297
- if (needsParams !== config.has("params")) {
353
+ const ignored = filesystem && transparent(roots[0], dirname(file));
354
+ if (!auto && !ignored && needsParams !== config.has("params")) {
298
355
  fail(configNode, "Local params must be present exactly when the path declares placeholders");
299
356
  }
300
357
  const data = config.get("data");
@@ -316,6 +373,7 @@ export async function discover(workspace, config, scans, out) {
316
373
  file,
317
374
  id,
318
375
  source,
376
+ rawText: authored.get(resolve(file)) ?? (await readFile(file, "utf8")),
319
377
  config,
320
378
  path: path.text,
321
379
  segments,
@@ -325,19 +383,35 @@ export async function discover(workspace, config, scans, out) {
325
383
  full: false,
326
384
  };
327
385
  const ownFacade = resolve(dirname(file), "route.generated.ts");
328
- const resolved = resolveModule(moduleText(imported), file, parsed.options);
386
+ const resolved = resolveModule(moduleText(imported), file, parsed.options, syntaxHost);
329
387
  const facadeSpelling = resolve(dirname(file), moduleText(imported)).replace(/\.js$/, ".ts");
330
388
  if (resolved === ownFacade || (!resolved && facadeSpelling === ownFacade)) {
331
389
  deferredFactories.push(declaration);
332
390
  }
333
391
  else {
334
- const genuine = await genuineImport(imported, factoryEntry, parsed.options);
392
+ const genuine = await genuineImport(imported, factoryEntry, parsed.options, syntaxHost);
335
393
  if (!genuine) {
336
394
  fail(imported, "defineRoute import does not resolve to this package's concrete public entry");
337
395
  }
338
396
  }
339
397
  declarations.push(declaration);
340
398
  }
399
+ if (auto) {
400
+ const edits = await synchronize(roots[0], declarations, pages, parsed.options, auto, syntaxProgram.getTypeChecker());
401
+ return discover(workspace, config, scans, out, undefined, edits, true, launch, compiler);
402
+ }
403
+ if (filesystem) {
404
+ for (let index = declarations.length - 1; index >= 0; index -= 1) {
405
+ const declaration = declarations[index];
406
+ if (transparent(roots[0], dirname(declaration.file))) {
407
+ declarations.splice(index, 1);
408
+ const deferred = deferredFactories.indexOf(declaration);
409
+ if (deferred >= 0) {
410
+ deferredFactories.splice(deferred, 1);
411
+ }
412
+ }
413
+ }
414
+ }
341
415
  const byDirectory = new Map();
342
416
  const byFile = new Map();
343
417
  for (const declaration of declarations) {
@@ -365,7 +439,7 @@ export async function discover(workspace, config, scans, out) {
365
439
  statement.importClause?.name?.text !== node.text) {
366
440
  continue;
367
441
  }
368
- const file = resolveModule(moduleText(statement), declaration.file, compilerOptions);
442
+ const file = resolveModule(moduleText(statement), declaration.file, compilerOptions, syntaxHost);
369
443
  const target = file && byFile.get(file);
370
444
  if (target) {
371
445
  return target;
@@ -480,6 +554,10 @@ export async function discover(workspace, config, scans, out) {
480
554
  fail(declaration.source, "Factory facade is not a final retained generated location");
481
555
  }
482
556
  }
557
+ let launchSources;
558
+ if (launchEntry) {
559
+ launchSources = { typeEntry: launchEntry, declarations: launchDeclarations };
560
+ }
483
561
  return {
484
562
  configFile,
485
563
  parsed,
@@ -489,5 +567,8 @@ export async function discover(workspace, config, scans, out) {
489
567
  declarations,
490
568
  factoryEntry,
491
569
  deferredFactories,
570
+ authored,
571
+ filesystem,
572
+ launch: launchSources,
492
573
  };
493
574
  }
@@ -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>;
@@ -3,6 +3,8 @@ import { realpath } from "node:fs/promises";
3
3
  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
+ import { overlayHost } from "./overlay.js";
7
+ import { AnchorState } from "./anchor.js";
6
8
  export function authoredPath(declaration) {
7
9
  const parts = [];
8
10
  for (const segment of declaration.effective) {
@@ -110,9 +112,13 @@ export function selections(sources) {
110
112
  }
111
113
  return result;
112
114
  }
113
- // Overlay only the final generated locations. Never evaluate application code.
114
- export async function validateProgram(sources, generated, stale) {
115
- const host = ts.createCompilerHost(sources.parsed.options);
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);
116
122
  const originalRead = host.readFile.bind(host);
117
123
  const originalExists = host.fileExists.bind(host);
118
124
  const originalDirectoryExists = host.directoryExists.bind(host);
@@ -140,14 +146,38 @@ export async function validateProgram(sources, generated, stale) {
140
146
  }
141
147
  return ts.createSourceFile(file, text, language, true);
142
148
  };
143
- const program = ts.createProgram({
144
- rootNames: [
145
- ...sources.parsed.fileNames.filter((file) => !obsolete.has(resolve(file))),
146
- sources.factoryEntry,
147
- ],
148
- options: sources.parsed.options,
149
- host,
150
- });
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;
167
+ if (sources.filesystem) {
168
+ const diagnostics = [];
169
+ for (const declaration of sources.declarations) {
170
+ const source = program.getSourceFile(declaration.file);
171
+ diagnostics.push(...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source));
172
+ }
173
+ if (diagnostics.length > 0) {
174
+ throw new Error(ts.formatDiagnostics(diagnostics, {
175
+ getCanonicalFileName: (name) => name,
176
+ getCurrentDirectory: () => dirname(sources.configFile),
177
+ getNewLine: () => "\n",
178
+ }));
179
+ }
180
+ }
151
181
  const checker = program.getTypeChecker();
152
182
  const entry = program.getSourceFile(sources.factoryEntry);
153
183
  const entryModule = checker.getSymbolAtLocation(entry);
@@ -161,20 +191,8 @@ export async function validateProgram(sources, generated, stale) {
161
191
  // Anchor the public export's declaration independently of application paths,
162
192
  // which can also redirect imports inside the concrete public type entry.
163
193
  const canonicalOptions = { ...sources.parsed.options, paths: undefined, baseUrl: undefined };
164
- const canonicalProgram = ts.createProgram({
165
- rootNames: [sources.factoryEntry],
166
- options: canonicalOptions,
167
- host: ts.createCompilerHost(canonicalOptions, true),
168
- });
169
- const canonicalChecker = canonicalProgram.getTypeChecker();
170
- const canonicalEntry = canonicalProgram.getSourceFile(sources.factoryEntry);
171
- const canonicalModule = canonicalChecker.getSymbolAtLocation(canonicalEntry);
172
- let canonicalFactory = canonicalChecker
173
- .getExportsOfModule(canonicalModule)
174
- .find((symbol) => symbol.name === "defineRoute");
175
- if (canonicalFactory.flags & ts.SymbolFlags.Alias) {
176
- canonicalFactory = canonicalChecker.getAliasedSymbol(canonicalFactory);
177
- }
194
+ const anchor = await (canonical ?? new AnchorState()).anchor(sources.factoryEntry, ["defineRoute"], canonicalOptions);
195
+ const canonicalFactory = anchor.get("defineRoute");
178
196
  // Tentative own-facade admission is not authority for its re-exported binding.
179
197
  // Prove both the concrete module boundary and the final native alias identity.
180
198
  for (const declaration of sources.declarations) {
@@ -200,17 +218,14 @@ export async function validateProgram(sources, generated, stale) {
200
218
  }
201
219
  if (!actualFactory?.declarations?.length ||
202
220
  actualFactory !== concreteFactory ||
203
- actualFactory.declarations.length !== canonicalFactory.declarations?.length) {
221
+ actualFactory.declarations.length !== canonicalFactory?.length) {
204
222
  fail(call, "Factory binding does not resolve to the concrete defineRoute export");
205
223
  }
206
224
  for (let index = 0; index < actualFactory.declarations.length; index += 1) {
207
225
  const actual = actualFactory.declarations[index];
208
- const canonical = canonicalFactory.declarations[index];
209
- const [actualFile, canonicalFile] = await Promise.all([
210
- realpath(actual.getSourceFile().fileName),
211
- realpath(canonical.getSourceFile().fileName),
212
- ]);
213
- if (actualFile !== canonicalFile ||
226
+ const canonical = canonicalFactory[index];
227
+ const actualFile = await realpath(actual.getSourceFile().fileName);
228
+ if (actualFile !== canonical.file ||
214
229
  actual.kind !== canonical.kind ||
215
230
  actual.pos !== canonical.pos ||
216
231
  actual.end !== canonical.end) {
@@ -222,6 +237,10 @@ export async function validateProgram(sources, generated, stale) {
222
237
  resolve(sources.output, "routes.generated.ts"),
223
238
  resolve(sources.output, "destinations.generated.ts"),
224
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
+ }
225
244
  for (const source of program.getSourceFiles()) {
226
245
  if (source.fileName.includes("/node_modules/") ||
227
246
  program.isSourceFileFromExternalLibrary(source) ||
@@ -277,7 +296,8 @@ export async function validateProgram(sources, generated, stale) {
277
296
  candidates.add(resolve(file));
278
297
  }
279
298
  for (const candidate of candidates) {
280
- const facadeTarget = basename(candidate) === "route.generated.ts" &&
299
+ const facadeTarget = (basename(candidate) === "route.generated.ts" ||
300
+ (sources.launch && basename(candidate) === "rpc.generated.ts")) &&
281
301
  !candidate.split(sep).includes("node_modules") &&
282
302
  sources.roots.some((root) => contains(root, candidate));
283
303
  if ((facadeTarget || globalTargets.has(candidate)) && !generated.has(candidate)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loozo/angular-router",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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
  },