@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.
- package/.npmignore +3 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/builders.json +15 -0
- package/fesm2022/loozo-angular-router-_core.mjs +565 -0
- package/fesm2022/loozo-angular-router-_core.mjs.map +1 -0
- package/fesm2022/loozo-angular-router-_generated.mjs +658 -0
- package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -0
- package/fesm2022/loozo-angular-router.mjs +6 -0
- package/fesm2022/loozo-angular-router.mjs.map +1 -0
- package/node/_core/src/path.js +50 -0
- package/node/src/builders/application.js +40 -0
- package/node/src/builders/options.js +70 -0
- package/node/src/builders/serve.js +155 -0
- package/node/src/generation/emit.js +245 -0
- package/node/src/generation/files.js +168 -0
- package/node/src/generation/generate.js +19 -0
- package/node/src/generation/selectors.js +60 -0
- package/node/src/generation/sources.js +493 -0
- package/node/src/generation/validate.js +463 -0
- package/package.json +47 -3
- package/schemas/application.json +17 -0
- package/schemas/serve.json +16 -0
- package/types/loozo-angular-router-_core.d.ts +185 -0
- package/types/loozo-angular-router-_generated.d.ts +56 -0
- package/types/loozo-angular-router.d.ts +1 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve, sep } from "node:path";
|
|
4
|
+
import { auditPath, contains } from "./files.js";
|
|
5
|
+
import { fail, properties, resolveModule } from "./sources.js";
|
|
6
|
+
export function authoredPath(declaration) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
for (const segment of declaration.effective) {
|
|
9
|
+
if (segment.kind === "static") {
|
|
10
|
+
parts.push(segment.text);
|
|
11
|
+
}
|
|
12
|
+
else if (segment.kind === "catchall") {
|
|
13
|
+
parts.push(`$${segment.name}[]`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
parts.push(`$${segment.name}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return `/${parts.join("/")}`;
|
|
20
|
+
}
|
|
21
|
+
function compatible(a, b) {
|
|
22
|
+
return a.kind !== "static" || b.kind !== "static" || a.text === b.text;
|
|
23
|
+
}
|
|
24
|
+
function ancestor(a, b) {
|
|
25
|
+
let parent = b.parent;
|
|
26
|
+
while (parent) {
|
|
27
|
+
if (parent === a) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
parent = parent.parent;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export function selections(sources) {
|
|
35
|
+
const declarations = sources.declarations.filter((declaration) => declaration.retained || declaration.target);
|
|
36
|
+
for (let left = 0; left < declarations.length; left += 1) {
|
|
37
|
+
const a = declarations[left];
|
|
38
|
+
for (let right = left + 1; right < declarations.length; right += 1) {
|
|
39
|
+
const b = declarations[right];
|
|
40
|
+
const catchA = a.effective.at(-1)?.kind === "catchall";
|
|
41
|
+
const catchB = b.effective.at(-1)?.kind === "catchall";
|
|
42
|
+
if (catchA || catchB) {
|
|
43
|
+
if (catchA && catchB) {
|
|
44
|
+
let scopeA = a.parent;
|
|
45
|
+
let scopeB = b.parent;
|
|
46
|
+
if (a.target) {
|
|
47
|
+
scopeA = undefined;
|
|
48
|
+
}
|
|
49
|
+
if (b.target) {
|
|
50
|
+
scopeB = undefined;
|
|
51
|
+
}
|
|
52
|
+
if (scopeA === scopeB) {
|
|
53
|
+
const length = Math.min(a.effective.length, b.effective.length) - 1;
|
|
54
|
+
let overlap = true;
|
|
55
|
+
for (let index = 0; index < length; index += 1) {
|
|
56
|
+
if (!compatible(a.effective[index], b.effective[index])) {
|
|
57
|
+
overlap = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (overlap) {
|
|
61
|
+
fail(a.source, `Competing sibling catchalls: ${a.id}; ${b.id}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (a.effective.length !== b.effective.length) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let overlap = true;
|
|
71
|
+
for (let index = 0; index < a.effective.length; index += 1) {
|
|
72
|
+
if (!compatible(a.effective[index], b.effective[index])) {
|
|
73
|
+
overlap = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (overlap && !ancestor(a, b) && !ancestor(b, a)) {
|
|
77
|
+
fail(a.source, `Ordinary route collision/overlap: ${a.id} (${authoredPath(a)}); ${b.id} (${authoredPath(b)})`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const groups = new Map();
|
|
82
|
+
for (const declaration of declarations) {
|
|
83
|
+
const path = authoredPath(declaration);
|
|
84
|
+
const group = groups.get(path) ?? [];
|
|
85
|
+
group.push(declaration);
|
|
86
|
+
groups.set(path, group);
|
|
87
|
+
}
|
|
88
|
+
const result = new Map();
|
|
89
|
+
for (const [path, group] of groups) {
|
|
90
|
+
if (group.length === 1) {
|
|
91
|
+
result.set(path, group[0]);
|
|
92
|
+
if (group[0].path === "" && !group[0].children.some((child) => child.retained)) {
|
|
93
|
+
group[0].full = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const leaf = group.find((declaration) => !declaration.children.some((child) => child.retained));
|
|
98
|
+
if (leaf && leaf.path === "") {
|
|
99
|
+
leaf.full = true;
|
|
100
|
+
result.set(path, leaf);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const declaration of declarations) {
|
|
104
|
+
if (declaration.target) {
|
|
105
|
+
if ((!declaration.target.retained && !declaration.target.target) ||
|
|
106
|
+
result.get(authoredPath(declaration.target)) !== declaration.target) {
|
|
107
|
+
fail(declaration.source, `Redirect target is pruned or not the canonical shared-path target: ${declaration.target.id}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
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);
|
|
116
|
+
const originalRead = host.readFile.bind(host);
|
|
117
|
+
const originalExists = host.fileExists.bind(host);
|
|
118
|
+
const originalDirectoryExists = host.directoryExists.bind(host);
|
|
119
|
+
host.directoryExists = (directory) => {
|
|
120
|
+
for (const file of generated.keys()) {
|
|
121
|
+
if (contains(resolve(directory), file)) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return originalDirectoryExists(directory);
|
|
126
|
+
};
|
|
127
|
+
const obsolete = new Set(stale);
|
|
128
|
+
host.readFile = (file) => {
|
|
129
|
+
const path = resolve(file);
|
|
130
|
+
if (obsolete.has(path)) {
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
return generated.get(path) ?? originalRead(file);
|
|
134
|
+
};
|
|
135
|
+
host.fileExists = (file) => generated.has(resolve(file)) || originalExists(file);
|
|
136
|
+
host.getSourceFile = (file, language) => {
|
|
137
|
+
const text = host.readFile(file);
|
|
138
|
+
if (text === undefined) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return ts.createSourceFile(file, text, language, true);
|
|
142
|
+
};
|
|
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
|
+
});
|
|
151
|
+
const checker = program.getTypeChecker();
|
|
152
|
+
const entry = program.getSourceFile(sources.factoryEntry);
|
|
153
|
+
const entryModule = checker.getSymbolAtLocation(entry);
|
|
154
|
+
const exportedFactory = checker
|
|
155
|
+
.getExportsOfModule(entryModule)
|
|
156
|
+
.find((symbol) => symbol.name === "defineRoute");
|
|
157
|
+
let concreteFactory = exportedFactory;
|
|
158
|
+
if (exportedFactory.flags & ts.SymbolFlags.Alias) {
|
|
159
|
+
concreteFactory = checker.getAliasedSymbol(exportedFactory);
|
|
160
|
+
}
|
|
161
|
+
// Anchor the public export's declaration independently of application paths,
|
|
162
|
+
// which can also redirect imports inside the concrete public type entry.
|
|
163
|
+
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
|
+
}
|
|
178
|
+
// Tentative own-facade admission is not authority for its re-exported binding.
|
|
179
|
+
// Prove both the concrete module boundary and the final native alias identity.
|
|
180
|
+
for (const declaration of sources.declarations) {
|
|
181
|
+
const source = program.getSourceFile(declaration.file);
|
|
182
|
+
const assignment = source.statements.find(ts.isExportAssignment);
|
|
183
|
+
const call = assignment.expression;
|
|
184
|
+
if (sources.deferredFactories.includes(declaration)) {
|
|
185
|
+
const facade = program.getSourceFile(resolve(dirname(declaration.file), "route.generated.ts"));
|
|
186
|
+
const reexport = facade.statements.find((statement) => ts.isExportDeclaration(statement) &&
|
|
187
|
+
!!statement.exportClause &&
|
|
188
|
+
ts.isNamedExports(statement.exportClause) &&
|
|
189
|
+
statement.exportClause.elements.some((element) => element.name.text === "defineRoute"));
|
|
190
|
+
const moduleSpecifier = reexport.moduleSpecifier;
|
|
191
|
+
const resolved = resolveModule(moduleSpecifier.text, facade.fileName, sources.parsed.options, host);
|
|
192
|
+
if (!resolved || (await realpath(resolved)) !== sources.factoryEntry) {
|
|
193
|
+
fail(call, "Factory facade does not re-export this package's concrete public entry");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const importedFactory = checker.getSymbolAtLocation(call.expression);
|
|
197
|
+
let actualFactory = importedFactory;
|
|
198
|
+
if (importedFactory && importedFactory.flags & ts.SymbolFlags.Alias) {
|
|
199
|
+
actualFactory = checker.getAliasedSymbol(importedFactory);
|
|
200
|
+
}
|
|
201
|
+
if (!actualFactory?.declarations?.length ||
|
|
202
|
+
actualFactory !== concreteFactory ||
|
|
203
|
+
actualFactory.declarations.length !== canonicalFactory.declarations?.length) {
|
|
204
|
+
fail(call, "Factory binding does not resolve to the concrete defineRoute export");
|
|
205
|
+
}
|
|
206
|
+
for (let index = 0; index < actualFactory.declarations.length; index += 1) {
|
|
207
|
+
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 ||
|
|
214
|
+
actual.kind !== canonical.kind ||
|
|
215
|
+
actual.pos !== canonical.pos ||
|
|
216
|
+
actual.end !== canonical.end) {
|
|
217
|
+
fail(call, "Factory binding is not the concrete defineRoute declaration");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const globalTargets = new Set([
|
|
222
|
+
resolve(sources.output, "routes.generated.ts"),
|
|
223
|
+
resolve(sources.output, "destinations.generated.ts"),
|
|
224
|
+
]);
|
|
225
|
+
for (const source of program.getSourceFiles()) {
|
|
226
|
+
if (source.fileName.includes("/node_modules/") ||
|
|
227
|
+
program.isSourceFileFromExternalLibrary(source) ||
|
|
228
|
+
program.isSourceFileDefaultLibrary(source)) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!generated.has(resolve(source.fileName))) {
|
|
232
|
+
await auditPath(source.fileName);
|
|
233
|
+
}
|
|
234
|
+
const references = [];
|
|
235
|
+
function visit(node) {
|
|
236
|
+
let specifier;
|
|
237
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
238
|
+
specifier = node.moduleSpecifier;
|
|
239
|
+
}
|
|
240
|
+
else if (ts.isCallExpression(node) &&
|
|
241
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
242
|
+
specifier = node.arguments[0];
|
|
243
|
+
}
|
|
244
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
245
|
+
specifier = node.argument.literal;
|
|
246
|
+
}
|
|
247
|
+
else if (ts.isImportEqualsDeclaration(node) &&
|
|
248
|
+
ts.isExternalModuleReference(node.moduleReference)) {
|
|
249
|
+
specifier = node.moduleReference.expression;
|
|
250
|
+
}
|
|
251
|
+
if (specifier && ts.isStringLiteralLike(specifier)) {
|
|
252
|
+
references.push(specifier);
|
|
253
|
+
}
|
|
254
|
+
ts.forEachChild(node, visit);
|
|
255
|
+
}
|
|
256
|
+
visit(source);
|
|
257
|
+
for (const specifier of references) {
|
|
258
|
+
const name = specifier.text;
|
|
259
|
+
const candidates = new Set();
|
|
260
|
+
const resolutionHost = {
|
|
261
|
+
...host,
|
|
262
|
+
// Omit the optional directory shortcut so native resolution probes files
|
|
263
|
+
// even below absent directories. Observe only; never invent existence.
|
|
264
|
+
directoryExists: undefined,
|
|
265
|
+
fileExists(file) {
|
|
266
|
+
candidates.add(resolve(file));
|
|
267
|
+
return host.fileExists(file);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const file = ts.resolveModuleName(name, source.fileName, sources.parsed.options, resolutionHost, undefined, undefined, program.getModeForUsageLocation(source, specifier)).resolvedModule?.resolvedFileName;
|
|
271
|
+
if (file && obsolete.has(resolve(file))) {
|
|
272
|
+
fail(specifier, `Generated import is not a retained owned target: ${name}`);
|
|
273
|
+
}
|
|
274
|
+
if (file) {
|
|
275
|
+
// A real resolved module wins over earlier failed search candidates.
|
|
276
|
+
candidates.clear();
|
|
277
|
+
candidates.add(resolve(file));
|
|
278
|
+
}
|
|
279
|
+
for (const candidate of candidates) {
|
|
280
|
+
const facadeTarget = basename(candidate) === "route.generated.ts" &&
|
|
281
|
+
!candidate.split(sep).includes("node_modules") &&
|
|
282
|
+
sources.roots.some((root) => contains(root, candidate));
|
|
283
|
+
if ((facadeTarget || globalTargets.has(candidate)) && !generated.has(candidate)) {
|
|
284
|
+
fail(specifier, `Generated import is not a retained owned target: ${name}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!file) {
|
|
288
|
+
const symbol = checker.getSymbolAtLocation(specifier);
|
|
289
|
+
const declaredAmbient = symbol?.declarations?.some((node) => ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name));
|
|
290
|
+
// Let the native checker resolve exact/pattern ambient declarations.
|
|
291
|
+
// Unknown/error placeholders have no actual declared module binding.
|
|
292
|
+
if (!symbol || !(symbol.flags & ts.SymbolFlags.Module) || !declaredAmbient) {
|
|
293
|
+
fail(specifier, `Unresolved import: ${name}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (file && !file.includes("/node_modules/") && !generated.has(resolve(file))) {
|
|
297
|
+
await auditPath(file);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function typeWitness(declaration, name) {
|
|
302
|
+
const source = program.getSourceFile(declaration.file);
|
|
303
|
+
const module = checker.getSymbolAtLocation(source);
|
|
304
|
+
let symbol = checker.getExportsOfModule(module).find((value) => value.name === "default");
|
|
305
|
+
if (symbol.flags & ts.SymbolFlags.Alias) {
|
|
306
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
307
|
+
}
|
|
308
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, source);
|
|
309
|
+
const witness = type.getProperty(name);
|
|
310
|
+
if (!witness) {
|
|
311
|
+
fail(source, `Cannot infer route ${name} for redirect compatibility`);
|
|
312
|
+
}
|
|
313
|
+
return checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(witness, source));
|
|
314
|
+
}
|
|
315
|
+
function entryType(type, key, node) {
|
|
316
|
+
const property = type.getProperty(key);
|
|
317
|
+
if (!property) {
|
|
318
|
+
fail(node, `Redirect input key is not declared: ${key}`);
|
|
319
|
+
}
|
|
320
|
+
return checker.getTypeOfSymbolAtLocation(property, node);
|
|
321
|
+
}
|
|
322
|
+
for (const declaration of sources.declarations) {
|
|
323
|
+
if (!declaration.target) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const redirect = declaration.config.get("redirect");
|
|
327
|
+
const programSource = program.getSourceFile(declaration.file);
|
|
328
|
+
const assignment = programSource.statements.find(ts.isExportAssignment);
|
|
329
|
+
const config = properties(assignment.expression.arguments[1], "Route config");
|
|
330
|
+
const fields = properties(config.get("redirect"), "Redirect");
|
|
331
|
+
const sourceParams = typeWitness(declaration, "__params");
|
|
332
|
+
const targetParams = typeWitness(declaration.target, "__params");
|
|
333
|
+
const mapping = fields.get("params");
|
|
334
|
+
let supplied;
|
|
335
|
+
if (mapping) {
|
|
336
|
+
supplied = properties(mapping, "Redirect params");
|
|
337
|
+
for (const key of supplied.keys()) {
|
|
338
|
+
if (!targetParams.getProperty(key)) {
|
|
339
|
+
fail(mapping, `Unknown redirect mapping key: ${key}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const targetKey of targetParams.getProperties()) {
|
|
344
|
+
let sourceKey = targetKey.name;
|
|
345
|
+
if (supplied) {
|
|
346
|
+
const expression = supplied.get(targetKey.name);
|
|
347
|
+
if (!expression || !ts.isStringLiteral(expression)) {
|
|
348
|
+
fail(mapping, `Missing/nonliteral redirect mapping: ${targetKey.name}`);
|
|
349
|
+
}
|
|
350
|
+
sourceKey = expression.text;
|
|
351
|
+
}
|
|
352
|
+
const from = entryType(sourceParams, sourceKey, redirect);
|
|
353
|
+
const to = checker.getTypeOfSymbolAtLocation(targetKey, redirect);
|
|
354
|
+
if (!checker.isTypeAssignableTo(from, to)) {
|
|
355
|
+
fail(redirect, `Incompatible logical redirect params: ${sourceKey} -> ${targetKey.name}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const sourceSearch = typeWitness(declaration, "__search");
|
|
359
|
+
const targetSearch = typeWitness(declaration.target, "__search");
|
|
360
|
+
const search = fields.get("search");
|
|
361
|
+
const fragment = fields.get("fragment");
|
|
362
|
+
if (!search || !fragment) {
|
|
363
|
+
fail(redirect, "Redirect requires search and fragment policies");
|
|
364
|
+
}
|
|
365
|
+
const policy = properties(search, "Redirect search");
|
|
366
|
+
const mode = policy.get("mode");
|
|
367
|
+
if (!mode ||
|
|
368
|
+
!ts.isStringLiteral(mode) ||
|
|
369
|
+
!["drop", "preserve", "replace"].includes(mode.text)) {
|
|
370
|
+
fail(search, "Invalid redirect search mode");
|
|
371
|
+
}
|
|
372
|
+
for (const key of policy.keys()) {
|
|
373
|
+
if (key !== "mode" && (mode.text !== "replace" || key !== "values")) {
|
|
374
|
+
fail(search, `Unknown redirect search policy key: ${key}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const fragmentPolicy = properties(fragment, "Redirect fragment");
|
|
378
|
+
const fragmentMode = fragmentPolicy.get("mode");
|
|
379
|
+
if (!fragmentMode ||
|
|
380
|
+
!ts.isStringLiteral(fragmentMode) ||
|
|
381
|
+
!["preserve", "drop", "replace"].includes(fragmentMode.text)) {
|
|
382
|
+
fail(fragment, "Invalid redirect fragment mode");
|
|
383
|
+
}
|
|
384
|
+
for (const key of fragmentPolicy.keys()) {
|
|
385
|
+
if (key !== "mode" && (fragmentMode.text !== "replace" || key !== "value")) {
|
|
386
|
+
fail(fragment, `Unknown redirect fragment policy key: ${key}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (fragmentMode.text === "replace") {
|
|
390
|
+
const value = fragmentPolicy.get("value");
|
|
391
|
+
const targetSource = program.getSourceFile(declaration.target.file);
|
|
392
|
+
const targetAssignment = targetSource.statements.find(ts.isExportAssignment);
|
|
393
|
+
const targetConfig = properties(targetAssignment.expression.arguments[1], "Route config");
|
|
394
|
+
const names = targetConfig.get("fragments");
|
|
395
|
+
if (!value || !names) {
|
|
396
|
+
fail(fragment, "Fragment replacement requires a destination-declared name");
|
|
397
|
+
}
|
|
398
|
+
const allowed = checker.getIndexTypeOfType(checker.getTypeAtLocation(names), ts.IndexKind.Number);
|
|
399
|
+
if (!allowed || !checker.isTypeAssignableTo(checker.getTypeAtLocation(value), allowed)) {
|
|
400
|
+
fail(value, "Redirect replacement fragment is not declared by its destination");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const required = targetSearch
|
|
404
|
+
.getProperties()
|
|
405
|
+
.filter((property) => !(property.flags & ts.SymbolFlags.Optional));
|
|
406
|
+
if (mode.text === "drop" && required.length > 0) {
|
|
407
|
+
fail(search, `Cannot drop required destination search: ${required.map((property) => property.name).join(", ")}`);
|
|
408
|
+
}
|
|
409
|
+
if (mode.text === "preserve") {
|
|
410
|
+
for (const key of required) {
|
|
411
|
+
const sourceKey = sourceSearch.getProperty(key.name);
|
|
412
|
+
if (!sourceKey ||
|
|
413
|
+
sourceKey.flags & ts.SymbolFlags.Optional ||
|
|
414
|
+
!checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(sourceKey, search), checker.getTypeOfSymbolAtLocation(key, search))) {
|
|
415
|
+
fail(search, `Source cannot guarantee required destination search: ${key.name}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (mode.text === "replace") {
|
|
420
|
+
const values = policy.get("values");
|
|
421
|
+
if (!values) {
|
|
422
|
+
fail(search, "Redirect replacement requires values");
|
|
423
|
+
}
|
|
424
|
+
const type = checker.getTypeAtLocation(values);
|
|
425
|
+
for (const key of type.getProperties()) {
|
|
426
|
+
const targetKey = targetSearch.getProperty(key.name);
|
|
427
|
+
if (!targetKey ||
|
|
428
|
+
!checker.isTypeAssignableTo(checker.getTypeOfSymbolAtLocation(key, values), checker.getTypeOfSymbolAtLocation(targetKey, values))) {
|
|
429
|
+
fail(values, `Unknown/incompatible redirect replacement key: ${key.name}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const key of required) {
|
|
433
|
+
const supplied = type.getProperty(key.name);
|
|
434
|
+
if (!supplied || supplied.flags & ts.SymbolFlags.Optional) {
|
|
435
|
+
fail(values, `Missing required redirect replacement: ${key.name}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Native Bundler resolution must preserve the discovered lazy component identity.
|
|
441
|
+
const routesFile = resolve(sources.output, "routes.generated.ts");
|
|
442
|
+
const routes = program.getSourceFile(routesFile);
|
|
443
|
+
if (routes) {
|
|
444
|
+
const promises = [];
|
|
445
|
+
function visit(node) {
|
|
446
|
+
if (ts.isCallExpression(node) &&
|
|
447
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
448
|
+
node.arguments[0] &&
|
|
449
|
+
ts.isStringLiteral(node.arguments[0])) {
|
|
450
|
+
const name = node.arguments[0].text;
|
|
451
|
+
const resolved = resolveModule(name, routesFile, sources.parsed.options, host);
|
|
452
|
+
if (!resolved ||
|
|
453
|
+
!sources.declarations.some((declaration) => declaration.page === resolved)) {
|
|
454
|
+
fail(node, `Lazy page import does not resolve to its discovered component: ${name}`);
|
|
455
|
+
}
|
|
456
|
+
promises.push(realpath(resolved).then(() => undefined));
|
|
457
|
+
}
|
|
458
|
+
ts.forEachChild(node, visit);
|
|
459
|
+
}
|
|
460
|
+
visit(routes);
|
|
461
|
+
await Promise.all(promises);
|
|
462
|
+
}
|
|
463
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loozo/angular-router",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Typed route declarations, URLs, navigation and route handles for Angular's native router.",
|
|
5
|
+
"bugs": "https://github.com/theo-matzavinos/loozo/issues",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Theophilos Matzavinos",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/theo-matzavinos/loozo.git",
|
|
11
|
+
"directory": "packages/angular-router"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./types/loozo-angular-router.d.ts",
|
|
17
|
+
"default": "./fesm2022/loozo-angular-router.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./_core": {
|
|
20
|
+
"types": "./types/loozo-angular-router-_core.d.ts",
|
|
21
|
+
"default": "./fesm2022/loozo-angular-router-_core.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./_generated": {
|
|
24
|
+
"types": "./types/loozo-angular-router-_generated.d.ts",
|
|
25
|
+
"default": "./fesm2022/loozo-angular-router-_generated.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": {
|
|
28
|
+
"default": "./package.json"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org/"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@angular-devkit/architect": "0.2201.5",
|
|
37
|
+
"chokidar": "5.0.0",
|
|
38
|
+
"rxjs": "7.8.2",
|
|
39
|
+
"tslib": "2.8.1",
|
|
40
|
+
"typescript": "6.0.3"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@angular/core": "^22.0.0",
|
|
44
|
+
"@angular/router": "^22.0.0"
|
|
45
|
+
},
|
|
46
|
+
"builders": "./builders.json",
|
|
47
|
+
"module": "fesm2022/loozo-angular-router.mjs",
|
|
48
|
+
"typings": "types/loozo-angular-router.d.ts",
|
|
49
|
+
"sideEffects": false
|
|
6
50
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"required": ["tsconfig", "scanDirectories", "outDirectory", "delegateTarget"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"tsconfig": { "type": "string", "minLength": 1 },
|
|
8
|
+
"scanDirectories": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"minItems": 1,
|
|
11
|
+
"items": { "type": "string", "minLength": 1 }
|
|
12
|
+
},
|
|
13
|
+
"outDirectory": { "type": "string", "minLength": 1 },
|
|
14
|
+
"delegateTarget": { "type": "string", "minLength": 1 },
|
|
15
|
+
"check": { "type": "boolean", "default": false }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"required": ["tsconfig", "scanDirectories", "outDirectory", "delegateTarget"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"tsconfig": { "type": "string", "minLength": 1 },
|
|
8
|
+
"scanDirectories": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"minItems": 1,
|
|
11
|
+
"items": { "type": "string", "minLength": 1 }
|
|
12
|
+
},
|
|
13
|
+
"outDirectory": { "type": "string", "minLength": 1 },
|
|
14
|
+
"delegateTarget": { "type": "string", "minLength": 1 }
|
|
15
|
+
}
|
|
16
|
+
}
|