@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.
- package/README.md +243 -121
- package/fesm2022/loozo-angular-router-_core.mjs +19 -41
- package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
- package/fesm2022/loozo-angular-router-_generated.mjs +148 -315
- package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
- package/fesm2022/loozo-angular-router.mjs +2 -2
- package/node/src/builders/application.js +12 -3
- package/node/src/builders/options.js +9 -3
- package/node/src/builders/serve.js +21 -14
- package/node/src/generation/emit.js +26 -8
- package/node/src/generation/filesystem.js +261 -69
- package/node/src/generation/generate.d.ts +1 -1
- package/node/src/generation/generate.js +6 -1
- package/node/src/generation/sources.js +7 -2
- package/node/src/generation/validate.js +15 -24
- package/package.json +1 -1
- package/schemas/application.json +29 -20
- package/schemas/serve.json +142 -7
- package/snippets/angular.json +29 -0
- package/snippets/generated/destinations.generated.ts +126 -0
- package/snippets/generated/routes.generated.ts +35 -0
- package/snippets/src/catalog.ts +20 -0
- package/snippets/src/docs-url.ts +8 -0
- package/snippets/src/index.html +11 -0
- package/snippets/src/main.ts +6 -0
- package/snippets/src/product-url.ts +8 -0
- package/snippets/src/routes/products/$productId/page.ts +20 -0
- package/snippets/src/routes/products/$productId/route.generated.ts +24 -0
- package/snippets/src/routes/products/$productId/route.ts +7 -0
- package/snippets/src/routes/reports/$reportId/page.ts +9 -0
- package/snippets/src/routes/reports/$reportId/route.generated.ts +24 -0
- package/snippets/src/routes/reports/$reportId/route.ts +6 -0
- package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +15 -0
- package/snippets/src/routes/teams/$teamId/items/$id/links.ts +13 -0
- package/snippets/src/routes/teams/$teamId/items/$id/page.ts +9 -0
- package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +24 -0
- package/snippets/src/routes/teams/$teamId/items/$id/route.ts +18 -0
- package/snippets/src/routes/teams/$teamId/members/$memberId/page.ts +9 -0
- package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +24 -0
- package/snippets/src/routes/teams/$teamId/members/$memberId/route.ts +7 -0
- package/snippets/src/routes/teams/$teamId/old-items/$id/route.ts +13 -0
- package/snippets/src/routes/teams/$teamId/route.generated.ts +24 -0
- package/snippets/src/routes/teams/$teamId/route.ts +6 -0
- package/snippets/src/shell.ts +9 -0
- package/snippets/src/slug.ts +14 -0
- package/snippets/tsconfig.app.json +14 -0
- package/snippets/tsconfig.routes.json +5 -0
- package/types/loozo-angular-router-_core.d.ts +141 -99
- package/types/loozo-angular-router-_generated.d.ts +48 -31
- 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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
168
|
-
start: property.name.getStart(source),
|
|
169
|
-
end: property.name.end,
|
|
170
|
-
text: JSON.stringify(newName),
|
|
171
|
-
});
|
|
287
|
+
keys = readable;
|
|
172
288
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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 =
|
|
214
|
-
|
|
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 =
|
|
225
|
-
|
|
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))}
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
317
|
-
const
|
|
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
|
-
|
|
336
|
-
|
|
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)},
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
const supplied =
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
"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",
|
package/schemas/application.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
-
"$comment": "Generated by scripts/
|
|
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": "
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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",
|