@loozo/angular-router 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,493 @@
1
+ import ts from "typescript";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parsePath } from "../../_core/src/path.js";
6
+ import { auditPath, contains, metadata, walk, workspacePaths, markers } from "./files.js";
7
+ export function fail(node, message) {
8
+ const source = node.getSourceFile();
9
+ const position = source.getLineAndCharacterOfPosition(node.getStart());
10
+ throw new Error(`${source.fileName}:${position.line + 1}:${position.character + 1}: ${message}`);
11
+ }
12
+ export function properties(node, label) {
13
+ if (!ts.isObjectLiteralExpression(node)) {
14
+ fail(node, `${label} must be an inline object`);
15
+ }
16
+ const result = new Map();
17
+ for (const property of node.properties) {
18
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
19
+ fail(property, `${label} requires explicit unique keys; no spreads/accessors`);
20
+ }
21
+ const name = property.name;
22
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) {
23
+ fail(name, `${label} requires noncomputed keys`);
24
+ }
25
+ if (result.has(name.text)) {
26
+ fail(property, `Duplicate ${label} key: ${name.text}`);
27
+ }
28
+ let value;
29
+ if (ts.isPropertyAssignment(property)) {
30
+ value = property.initializer;
31
+ }
32
+ else {
33
+ value = property.name;
34
+ }
35
+ result.set(name.text, value);
36
+ }
37
+ return result;
38
+ }
39
+ export function resolveModule(name, source, options, host = ts.sys) {
40
+ return ts.resolveModuleName(name, source, options, host).resolvedModule?.resolvedFileName;
41
+ }
42
+ function namedImport(source, name, imported) {
43
+ if (!ts.isIdentifier(name)) {
44
+ return undefined;
45
+ }
46
+ for (const statement of source.statements) {
47
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) {
48
+ continue;
49
+ }
50
+ const bindings = statement.importClause?.namedBindings;
51
+ if (!bindings || !ts.isNamedImports(bindings)) {
52
+ continue;
53
+ }
54
+ for (const binding of bindings.elements) {
55
+ if (!binding.isTypeOnly &&
56
+ binding.name.text === name.text &&
57
+ (binding.propertyName?.text ?? binding.name.text) === imported) {
58
+ return statement;
59
+ }
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ async function installedAngular(source) {
65
+ let directory = dirname(source);
66
+ while (true) {
67
+ const packageFile = resolve(directory, "node_modules/@angular/core/package.json");
68
+ const info = await metadata(packageFile);
69
+ if (info) {
70
+ const bytes = await readFile(packageFile, "utf8");
71
+ const json = JSON.parse(bytes);
72
+ const entry = json.exports?.["."]?.types;
73
+ if (typeof entry !== "string") {
74
+ throw new Error(`Installed Angular has no types entry: ${packageFile}`);
75
+ }
76
+ return realpath(resolve(dirname(packageFile), entry));
77
+ }
78
+ const parent = dirname(directory);
79
+ if (parent === directory) {
80
+ throw new Error(`Cannot find consumer-installed @angular/core for ${source}`);
81
+ }
82
+ directory = parent;
83
+ }
84
+ }
85
+ function moduleText(statement) {
86
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) {
87
+ fail(statement, "Import module must be a string literal");
88
+ }
89
+ return statement.moduleSpecifier.text;
90
+ }
91
+ async function genuineImport(statement, expected, options) {
92
+ const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options);
93
+ if (!actual) {
94
+ return false;
95
+ }
96
+ const physical = await realpath(actual);
97
+ return physical === expected;
98
+ }
99
+ function isDefault(node) {
100
+ if (!ts.canHaveModifiers(node)) {
101
+ return false;
102
+ }
103
+ return (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ===
104
+ true);
105
+ }
106
+ async function component(source, node, options) {
107
+ let found = false;
108
+ for (const decorator of ts.getDecorators(node) ?? []) {
109
+ if (!ts.isCallExpression(decorator.expression)) {
110
+ continue;
111
+ }
112
+ const call = decorator.expression;
113
+ const imported = namedImport(source, call.expression, "Component");
114
+ if (!imported) {
115
+ continue;
116
+ }
117
+ const expected = await installedAngular(source.fileName);
118
+ const genuine = await genuineImport(imported, expected, options);
119
+ if (!genuine) {
120
+ fail(decorator, "Component decorator is not from consumer-installed @angular/core");
121
+ }
122
+ if (found || call.arguments.length !== 1 || !ts.isObjectLiteralExpression(call.arguments[0])) {
123
+ fail(decorator, "Component requires one inline metadata object");
124
+ }
125
+ found = true;
126
+ let proven = true;
127
+ let explicit = false;
128
+ for (const property of call.arguments[0].properties) {
129
+ if (ts.isSpreadAssignment(property) || ts.isComputedPropertyName(property.name)) {
130
+ proven = false;
131
+ continue;
132
+ }
133
+ const name = property.name;
134
+ if (name &&
135
+ (ts.isIdentifier(name) || ts.isStringLiteral(name)) &&
136
+ name.text === "standalone") {
137
+ if (explicit) {
138
+ fail(property, "Duplicate standalone property");
139
+ }
140
+ explicit = true;
141
+ proven =
142
+ ts.isPropertyAssignment(property) &&
143
+ property.initializer.kind === ts.SyntaxKind.TrueKeyword;
144
+ if (!proven) {
145
+ fail(property, "Component standalone must be final literal true or safely omitted");
146
+ }
147
+ }
148
+ }
149
+ if (!proven) {
150
+ fail(decorator, "Component metadata cannot prove standalone after opaque override");
151
+ }
152
+ }
153
+ if (!found) {
154
+ fail(node, "Default class must have a genuine Angular Component decorator");
155
+ }
156
+ }
157
+ const keys = new Set([
158
+ "parent",
159
+ "params",
160
+ "search",
161
+ "resolve",
162
+ "runGuardsAndResolvers",
163
+ "redirect",
164
+ "fragments",
165
+ "canActivate",
166
+ "canActivateChild",
167
+ "canMatch",
168
+ "canDeactivate",
169
+ "providers",
170
+ "title",
171
+ "data",
172
+ ]);
173
+ export async function discover(workspace, config, scans, out) {
174
+ const access = await workspacePaths(workspace);
175
+ const configFile = await access.resolve(config);
176
+ 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
+ }
184
+ }
185
+ 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 fatal = [];
190
+ const parsed = ts.getParsedCommandLineOfConfigFile(configFile, {}, {
191
+ ...ts.sys,
192
+ onUnRecoverableConfigFileDiagnostic: (diagnostic) => fatal.push(diagnostic),
193
+ });
194
+ const errors = [...fatal, ...(parsed?.errors ?? [])].filter((error) => error.code !== 18003);
195
+ if (!parsed || errors.length > 0) {
196
+ const codes = errors.map((diagnostic) => diagnostic.code).join(", ");
197
+ throw new Error(`TypeScript diagnostic codes: ${codes}\n` +
198
+ ts.formatDiagnosticsWithColorAndContext(errors, {
199
+ getCanonicalFileName: (name) => name,
200
+ getCurrentDirectory: () => configDirectory,
201
+ getNewLine: () => "\n",
202
+ }));
203
+ }
204
+ const candidates = [];
205
+ for (const file of parsed.fileNames) {
206
+ const normalized = await access.resolve(file);
207
+ if (!normalized.endsWith(".ts") ||
208
+ normalized.endsWith(".d.ts") ||
209
+ normalized.split(sep).includes("node_modules")) {
210
+ continue;
211
+ }
212
+ await auditPath(normalized);
213
+ if (roots.some((root) => contains(root, normalized))) {
214
+ candidates.push(normalized);
215
+ }
216
+ }
217
+ candidates.sort();
218
+ const packageRoot = fileURLToPath(new URL("../../../", import.meta.url));
219
+ const manifestBytes = await readFile(resolve(packageRoot, "package.json"), "utf8");
220
+ const manifest = JSON.parse(manifestBytes);
221
+ const factoryEntry = await realpath(resolve(packageRoot, manifest.exports["."].types));
222
+ const declarations = [];
223
+ const pages = new Map();
224
+ const deferredFactories = [];
225
+ const syntaxProgram = ts.createProgram({
226
+ rootNames: candidates,
227
+ options: parsed.options,
228
+ host: ts.createCompilerHost(parsed.options, true),
229
+ });
230
+ for (const file of candidates) {
231
+ const bytes = await readFile(file, "utf8");
232
+ if (file.endsWith(`${sep}route.generated.ts`) &&
233
+ bytes.split("\n", 1)[0] === markers["route.generated.ts"]) {
234
+ continue;
235
+ }
236
+ const source = syntaxProgram.getSourceFile(file);
237
+ const syntax = syntaxProgram.getSyntacticDiagnostics(source);
238
+ if (syntax.length > 0) {
239
+ throw new Error(ts.formatDiagnostics(syntax, {
240
+ getCanonicalFileName: (name) => name,
241
+ getCurrentDirectory: () => configDirectory,
242
+ getNewLine: () => "\n",
243
+ }));
244
+ }
245
+ const defaults = source.statements.filter((node) => {
246
+ if ((ts.isExportAssignment(node) && !node.isExportEquals) || isDefault(node)) {
247
+ return true;
248
+ }
249
+ if (ts.isExportDeclaration(node) &&
250
+ node.exportClause &&
251
+ ts.isNamedExports(node.exportClause)) {
252
+ return node.exportClause.elements.some((element) => element.name.text === "default");
253
+ }
254
+ return false;
255
+ });
256
+ if (defaults.length === 0) {
257
+ continue;
258
+ }
259
+ if (defaults.length !== 1) {
260
+ fail(defaults[1], "Multiple competing default exports");
261
+ }
262
+ const node = defaults[0];
263
+ if (ts.isClassDeclaration(node)) {
264
+ await component(source, node, parsed.options);
265
+ if (pages.has(dirname(file))) {
266
+ fail(node, `Competing colocated components: ${pages.get(dirname(file))}; ${file}`);
267
+ }
268
+ pages.set(dirname(file), file);
269
+ continue;
270
+ }
271
+ if (!ts.isExportAssignment(node) || !ts.isCallExpression(node.expression)) {
272
+ fail(node, "Default export must be a direct defineRoute call or Component class");
273
+ }
274
+ const call = node.expression;
275
+ const imported = namedImport(source, call.expression, "defineRoute");
276
+ if (!imported) {
277
+ fail(call, "Factory must be a genuine named value import of defineRoute, not a local wrapper/alias");
278
+ }
279
+ const [path, configNode] = call.arguments;
280
+ if (call.arguments.length !== 2 || !path || !ts.isStringLiteral(path) || !configNode) {
281
+ fail(call, "defineRoute requires a literal path and inline config");
282
+ }
283
+ const config = properties(configNode, "Route config");
284
+ for (const key of config.keys()) {
285
+ if (!keys.has(key)) {
286
+ fail(configNode, `Unknown or generation-owned route config key: ${key}`);
287
+ }
288
+ }
289
+ let segments;
290
+ try {
291
+ segments = parsePath(path.text);
292
+ }
293
+ catch (error) {
294
+ fail(path, String(error));
295
+ }
296
+ const needsParams = segments.some((segment) => segment.kind !== "static");
297
+ if (needsParams !== config.has("params")) {
298
+ fail(configNode, "Local params must be present exactly when the path declares placeholders");
299
+ }
300
+ const data = config.get("data");
301
+ if (data && ts.isObjectLiteralExpression(data)) {
302
+ for (const property of data.properties) {
303
+ const name = property.name;
304
+ if (name &&
305
+ (ts.isIdentifier(name) || ts.isStringLiteral(name)) &&
306
+ name.text === "__loozoAngularRouterRouteId") {
307
+ fail(data, "Static data cannot supply reserved generated identity");
308
+ }
309
+ }
310
+ }
311
+ const id = relative(configDirectory, file).split(sep).join("/");
312
+ if (isAbsolute(id)) {
313
+ fail(node, "Source identity cannot be represented relative to tsconfig");
314
+ }
315
+ const declaration = {
316
+ file,
317
+ id,
318
+ source,
319
+ config,
320
+ path: path.text,
321
+ segments,
322
+ effective: [],
323
+ children: [],
324
+ retained: true,
325
+ full: false,
326
+ };
327
+ const ownFacade = resolve(dirname(file), "route.generated.ts");
328
+ const resolved = resolveModule(moduleText(imported), file, parsed.options);
329
+ const facadeSpelling = resolve(dirname(file), moduleText(imported)).replace(/\.js$/, ".ts");
330
+ if (resolved === ownFacade || (!resolved && facadeSpelling === ownFacade)) {
331
+ deferredFactories.push(declaration);
332
+ }
333
+ else {
334
+ const genuine = await genuineImport(imported, factoryEntry, parsed.options);
335
+ if (!genuine) {
336
+ fail(imported, "defineRoute import does not resolve to this package's concrete public entry");
337
+ }
338
+ }
339
+ declarations.push(declaration);
340
+ }
341
+ const byDirectory = new Map();
342
+ const byFile = new Map();
343
+ for (const declaration of declarations) {
344
+ const directory = dirname(declaration.file);
345
+ if (byDirectory.has(directory)) {
346
+ fail(declaration.source, `Competing colocated declarations: ${byDirectory.get(directory).file}; ${declaration.file}`);
347
+ }
348
+ byDirectory.set(directory, declaration);
349
+ byFile.set(declaration.file, declaration);
350
+ declaration.page = pages.get(directory);
351
+ }
352
+ for (const [directory, page] of pages) {
353
+ if (!byDirectory.has(directory)) {
354
+ throw new Error(`Component without a colocated admitted declaration: ${page}`);
355
+ }
356
+ }
357
+ const compilerOptions = parsed.options;
358
+ function referenced(node, declaration) {
359
+ if (!ts.isIdentifier(node)) {
360
+ fail(node, "Parent/redirect target must directly reference a default import");
361
+ }
362
+ for (const statement of declaration.source.statements) {
363
+ if (!ts.isImportDeclaration(statement) ||
364
+ statement.importClause?.isTypeOnly ||
365
+ statement.importClause?.name?.text !== node.text) {
366
+ continue;
367
+ }
368
+ const file = resolveModule(moduleText(statement), declaration.file, compilerOptions);
369
+ const target = file && byFile.get(file);
370
+ if (target) {
371
+ return target;
372
+ }
373
+ fail(node, `Parent/redirect target is not an admitted declaration: ${file ?? moduleText(statement)}`);
374
+ }
375
+ fail(node, "Parent/redirect target must directly reference a default import");
376
+ }
377
+ for (const declaration of declarations) {
378
+ const parent = declaration.config.get("parent");
379
+ if (parent) {
380
+ declaration.parent = referenced(parent, declaration);
381
+ declaration.parent.children.push(declaration);
382
+ }
383
+ const redirect = declaration.config.get("redirect");
384
+ if (redirect) {
385
+ const fields = properties(redirect, "Redirect");
386
+ for (const key of fields.keys()) {
387
+ if (!["to", "params", "search", "fragment"].includes(key)) {
388
+ fail(redirect, `Unknown redirect key: ${key}`);
389
+ }
390
+ }
391
+ const target = fields.get("to");
392
+ if (!target) {
393
+ fail(redirect, "Redirect requires a direct target");
394
+ }
395
+ declaration.target = referenced(target, declaration);
396
+ if (declaration.page ||
397
+ declaration.children.length ||
398
+ declaration.config.has("canActivate") ||
399
+ declaration.config.has("canMatch")) {
400
+ fail(redirect, "Redirect cannot have a component, children, canActivate or canMatch");
401
+ }
402
+ declaration.retained = false;
403
+ }
404
+ }
405
+ const visiting = new Set();
406
+ const completed = new Set();
407
+ function ancestry(declaration) {
408
+ if (completed.has(declaration)) {
409
+ return;
410
+ }
411
+ if (visiting.has(declaration)) {
412
+ fail(declaration.source, "Parent cycle");
413
+ }
414
+ visiting.add(declaration);
415
+ if (declaration.parent) {
416
+ ancestry(declaration.parent);
417
+ if (declaration.parent.effective.some((segment) => segment.kind === "catchall")) {
418
+ fail(declaration.source, "Catchall declarations cannot have children");
419
+ }
420
+ const owned = new Set();
421
+ for (const segment of declaration.parent.effective) {
422
+ if (segment.kind !== "static") {
423
+ owned.add(segment.name);
424
+ }
425
+ }
426
+ for (const segment of declaration.segments) {
427
+ if (segment.kind !== "static" && owned.has(segment.name)) {
428
+ fail(declaration.source, `Ancestor already owns path param: ${segment.name}`);
429
+ }
430
+ }
431
+ declaration.effective = [...declaration.parent.effective, ...declaration.segments];
432
+ }
433
+ else {
434
+ declaration.effective = declaration.segments;
435
+ }
436
+ visiting.delete(declaration);
437
+ completed.add(declaration);
438
+ }
439
+ for (const declaration of declarations) {
440
+ ancestry(declaration);
441
+ const chain = new Set();
442
+ let current = declaration;
443
+ while (current?.target) {
444
+ if (chain.has(current)) {
445
+ fail(declaration.source, "Redirect cycle/self redirect");
446
+ }
447
+ chain.add(current);
448
+ current = current.target;
449
+ }
450
+ }
451
+ // Prune only ancestors made empty by redirect hoisting, not ordinary missing pages.
452
+ function retain(declaration) {
453
+ if (declaration.target) {
454
+ if (declaration.children.length > 0) {
455
+ fail(declaration.source, "Redirect cannot have authored children");
456
+ }
457
+ return false;
458
+ }
459
+ let retainedChild = false;
460
+ for (const child of declaration.children) {
461
+ if (retain(child)) {
462
+ retainedChild = true;
463
+ }
464
+ }
465
+ if (!declaration.page && !retainedChild) {
466
+ if (declaration.children.length === 0) {
467
+ fail(declaration.source, "Ordinary componentless leaf");
468
+ }
469
+ declaration.retained = false;
470
+ }
471
+ return declaration.retained;
472
+ }
473
+ for (const declaration of declarations) {
474
+ if (!declaration.parent) {
475
+ retain(declaration);
476
+ }
477
+ }
478
+ for (const declaration of deferredFactories) {
479
+ if (!declaration.retained) {
480
+ fail(declaration.source, "Factory facade is not a final retained generated location");
481
+ }
482
+ }
483
+ return {
484
+ configFile,
485
+ parsed,
486
+ roots,
487
+ output,
488
+ encountered,
489
+ declarations,
490
+ factoryEntry,
491
+ deferredFactories,
492
+ };
493
+ }