@loozo/angular-router 0.0.0-stage → 0.0.2

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,343 @@
1
+ import ts from "typescript";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { parsePath } from "../../_core/src/path.js";
4
+ import { auditPath, metadata } from "./files.js";
5
+ import { fail, properties, resolveModule } from "./sources.js";
6
+ import { specifier } from "./emit.js";
7
+ export function transparent(root, directory) {
8
+ return directory !== root && basename(directory).startsWith("-");
9
+ }
10
+ function localPath(root, directory) {
11
+ if (directory === root || basename(directory).startsWith("+")) {
12
+ return "";
13
+ }
14
+ return basename(directory);
15
+ }
16
+ function unusedBinding(source, preferred) {
17
+ const identifiers = new Set();
18
+ function visit(node) {
19
+ if (ts.isIdentifier(node)) {
20
+ identifiers.add(node.text);
21
+ }
22
+ ts.forEachChild(node, visit);
23
+ }
24
+ visit(source);
25
+ let name = preferred;
26
+ let index = 0;
27
+ while (identifiers.has(name)) {
28
+ index += 1;
29
+ name = `${preferred}${index}`;
30
+ }
31
+ return name;
32
+ }
33
+ function removeSyntax(node, source) {
34
+ const start = node.getStart(source);
35
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source.text);
36
+ scanner.setTextPos(node.getFullStart());
37
+ let text = "";
38
+ let hasComments = false;
39
+ while (true) {
40
+ const token = scanner.scan();
41
+ const comment = token === ts.SyntaxKind.SingleLineCommentTrivia ||
42
+ token === ts.SyntaxKind.MultiLineCommentTrivia;
43
+ const trivia = comment || token === ts.SyntaxKind.WhitespaceTrivia || token === ts.SyntaxKind.NewLineTrivia;
44
+ if (token === ts.SyntaxKind.EndOfFileToken || (scanner.getTokenPos() >= node.end && !trivia)) {
45
+ break;
46
+ }
47
+ // Include adjacent retained trivia: it too can become module-leading after
48
+ // 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())) {
51
+ fail(node, "Cannot remove directive-bound filesystem parent syntax; repair it explicitly");
52
+ }
53
+ if (scanner.getTokenPos() >= start && scanner.getTokenPos() < node.end && trivia) {
54
+ text += scanner.getTokenText();
55
+ hasComments ||= comment;
56
+ }
57
+ }
58
+ if (!hasComments) {
59
+ text = "";
60
+ }
61
+ return { start, end: node.end, text };
62
+ }
63
+ function removeProperty(property, source) {
64
+ const object = property.parent;
65
+ const index = object.properties.indexOf(property);
66
+ const patches = [removeSyntax(property, source)];
67
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, source.text);
68
+ if (index < object.properties.length - 1 || object.properties.hasTrailingComma) {
69
+ scanner.setTextPos(property.end);
70
+ }
71
+ else if (index > 0) {
72
+ scanner.setTextPos(object.properties[index - 1].end);
73
+ }
74
+ else {
75
+ return patches;
76
+ }
77
+ scanner.scan();
78
+ patches.push({ start: scanner.getTokenPos(), end: scanner.getTextPos(), text: "" });
79
+ return patches;
80
+ }
81
+ function addProperty(object, text) {
82
+ let prefix = "";
83
+ if (object.properties.length > 0 && !object.properties.hasTrailingComma) {
84
+ prefix = ",";
85
+ }
86
+ return { start: object.end - 1, end: object.end - 1, text: `${prefix} ${text} ` };
87
+ }
88
+ function parentImport(declaration, parent) {
89
+ if (!ts.isIdentifier(parent)) {
90
+ fail(parent, "Filesystem parent must directly reference a default import");
91
+ }
92
+ for (const statement of declaration.source.statements) {
93
+ if (ts.isImportDeclaration(statement) &&
94
+ !statement.importClause?.isTypeOnly &&
95
+ statement.importClause?.name?.text === parent.text &&
96
+ ts.isStringLiteral(statement.moduleSpecifier)) {
97
+ return statement;
98
+ }
99
+ }
100
+ fail(parent, "Filesystem parent must directly reference a default import");
101
+ }
102
+ function otherUses(source, imported, parent, checker) {
103
+ const binding = checker.getSymbolAtLocation(imported.importClause.name);
104
+ let used = false;
105
+ function visit(node) {
106
+ if (node === imported || node === parent) {
107
+ return;
108
+ }
109
+ if (ts.isIdentifier(node)) {
110
+ let reference = checker.getSymbolAtLocation(node);
111
+ if (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) {
112
+ reference = checker.getShorthandAssignmentValueSymbol(node.parent);
113
+ }
114
+ else if (ts.isExportSpecifier(node.parent)) {
115
+ reference = checker.getExportSpecifierLocalTargetSymbol(node.parent);
116
+ }
117
+ if (reference === binding) {
118
+ used = true;
119
+ }
120
+ }
121
+ ts.forEachChild(node, visit);
122
+ }
123
+ visit(source);
124
+ return used;
125
+ }
126
+ function patchDeclaration(declaration, path, parentFile, options, host, checker) {
127
+ const source = declaration.source;
128
+ const assignment = source.statements.find(ts.isExportAssignment);
129
+ const call = assignment.expression;
130
+ const literal = call.arguments[0];
131
+ const object = call.arguments[1];
132
+ const patches = [];
133
+ const additions = [];
134
+ const imports = [];
135
+ let newline = "\n";
136
+ if (source.text.includes("\r\n")) {
137
+ newline = "\r\n";
138
+ }
139
+ if (declaration.path !== path) {
140
+ const names = new Set();
141
+ const desiredNames = new Set();
142
+ for (const segment of declaration.segments) {
143
+ if (segment.kind !== "static") {
144
+ names.add(segment.name);
145
+ }
146
+ }
147
+ for (const segment of parsePath(path)) {
148
+ if (segment.kind !== "static") {
149
+ desiredNames.add(segment.name);
150
+ }
151
+ }
152
+ const sameNames = names.size === desiredNames.size && [...names].every((name) => desiredNames.has(name));
153
+ if (!sameNames) {
154
+ const params = declaration.config.get("params");
155
+ if (names.size === 1 && desiredNames.size === 1 && params) {
156
+ const entries = properties(params, "Filesystem local params");
157
+ const oldName = [...names][0];
158
+ const newName = [...desiredNames][0];
159
+ if (entries.size !== 1 || !entries.has(oldName)) {
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");
166
+ }
167
+ patches.push({
168
+ start: property.name.getStart(source),
169
+ end: property.name.end,
170
+ text: JSON.stringify(newName),
171
+ });
172
+ }
173
+ else if (names.size === 0 && desiredNames.size === 1 && !params) {
174
+ const name = unusedBinding(source, "filesystemStringParam");
175
+ imports.push(`import { stringParam as ${name} } from "@loozo/angular-router";${newline}`);
176
+ additions.push(`params: { ${JSON.stringify([...desiredNames][0])}: ${name} }`);
177
+ }
178
+ else {
179
+ fail(literal, "Ambiguous filesystem codec change; update path and local params explicitly before synchronization");
180
+ }
181
+ }
182
+ patches.push({ start: literal.getStart(source), end: literal.end, text: JSON.stringify(path) });
183
+ }
184
+ const parent = declaration.config.get("parent");
185
+ let parentName;
186
+ if (parent) {
187
+ const imported = parentImport(declaration, parent);
188
+ const module = imported.moduleSpecifier;
189
+ const resolved = resolveModule(module.text, declaration.file, options, host);
190
+ const usedElsewhere = otherUses(source, imported, parent, checker);
191
+ if (!parentFile || resolved !== parentFile) {
192
+ if (parentFile && !usedElsewhere && !imported.importClause?.namedBindings) {
193
+ patches.push({
194
+ start: module.getStart(source),
195
+ end: module.end,
196
+ text: JSON.stringify(specifier(declaration.file, parentFile)),
197
+ });
198
+ }
199
+ else {
200
+ if (!resolved && (usedElsewhere || imported.importClause?.namedBindings)) {
201
+ fail(imported, "Stale parent import has unrelated uses; repair it explicitly before filesystem synchronization");
202
+ }
203
+ if (!usedElsewhere && !imported.importClause?.namedBindings) {
204
+ patches.push(removeSyntax(imported, source));
205
+ }
206
+ const property = object.properties.find((entry) => entry.name &&
207
+ (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
208
+ entry.name.text === "parent");
209
+ if (!parentFile) {
210
+ patches.push(...removeProperty(property, source));
211
+ }
212
+ else {
213
+ parentName = unusedBinding(source, "filesystemParent");
214
+ let text = parentName;
215
+ if (ts.isShorthandPropertyAssignment(property)) {
216
+ text = `parent: ${parentName}`;
217
+ }
218
+ patches.push({ start: parent.getStart(source), end: parent.end, text });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ else if (parentFile) {
224
+ parentName = unusedBinding(source, "filesystemParent");
225
+ additions.push(`parent: ${parentName}`);
226
+ }
227
+ if (parentFile && parentName) {
228
+ // 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))};${newline}`);
230
+ }
231
+ if (additions.length > 0) {
232
+ patches.push(addProperty(object, additions.join(", ")));
233
+ }
234
+ if (imports.length > 0) {
235
+ // Static imports are hoisted. Appending at the complete module boundary
236
+ // preserves every existing statement/comment attachment without parsing it.
237
+ patches.push({
238
+ start: source.text.length,
239
+ end: source.text.length,
240
+ text: newline + imports.join(""),
241
+ });
242
+ }
243
+ patches.sort((a, b) => b.start - a.start);
244
+ let bytes = source.text;
245
+ for (const patch of patches) {
246
+ bytes = bytes.slice(0, patch.start) + patch.text + bytes.slice(patch.end);
247
+ }
248
+ // ts.sys.readFile strips a UTF-8 BOM; compiler positions refer to source.text.
249
+ // Apply positions there, then restore the raw prefix if the host stripped it.
250
+ if (declaration.rawText.startsWith("\uFEFF") && !source.text.startsWith("\uFEFF")) {
251
+ bytes = "\uFEFF" + bytes;
252
+ }
253
+ return bytes;
254
+ }
255
+ export async function synchronize(root, declarations, pages, options, { filename = "route.ts" }, checker) {
256
+ const byDirectory = new Map();
257
+ const participating = new Set();
258
+ for (const declaration of declarations) {
259
+ const directory = dirname(declaration.file);
260
+ if (byDirectory.has(directory)) {
261
+ fail(declaration.source, "Competing colocated declarations");
262
+ }
263
+ byDirectory.set(directory, declaration);
264
+ if (transparent(root, directory)) {
265
+ for (const key of declaration.config.keys()) {
266
+ if (key !== "parent") {
267
+ fail(declaration.source, "Transparent directory declaration has incompatible authored behavior");
268
+ }
269
+ }
270
+ continue;
271
+ }
272
+ participating.add(directory);
273
+ }
274
+ for (const [directory, page] of pages) {
275
+ if (transparent(root, directory)) {
276
+ throw new Error(`Transparent directory cannot own a page: ${page}`);
277
+ }
278
+ participating.add(directory);
279
+ }
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
+ const files = new Map();
290
+ for (const directory of [...participating].sort()) {
291
+ let file = byDirectory.get(directory)?.file;
292
+ if (!file) {
293
+ file = resolve(directory, filename);
294
+ await auditPath(file);
295
+ if (await metadata(file)) {
296
+ throw new Error(`Filesystem seed collision with an unadmitted file: ${file}`);
297
+ }
298
+ }
299
+ files.set(directory, file);
300
+ }
301
+ const plannedFiles = new Set(files.values());
302
+ const host = {
303
+ ...ts.sys,
304
+ fileExists: (file) => plannedFiles.has(resolve(file)) || ts.sys.fileExists(file),
305
+ };
306
+ const authored = new Map();
307
+ for (const [directory, file] of files) {
308
+ let parentFile;
309
+ if (directory !== root) {
310
+ let parent = dirname(directory);
311
+ while (transparent(root, parent)) {
312
+ parent = dirname(parent);
313
+ }
314
+ parentFile = files.get(parent);
315
+ }
316
+ const path = localPath(root, directory);
317
+ const segments = parsePath(path);
318
+ const declaration = byDirectory.get(directory);
319
+ if (declaration) {
320
+ const bytes = patchDeclaration(declaration, path, parentFile, options, host, checker);
321
+ if (bytes !== declaration.rawText) {
322
+ authored.set(file, bytes);
323
+ }
324
+ continue;
325
+ }
326
+ const imported = ["defineRoute"];
327
+ const config = [];
328
+ const imports = [];
329
+ if (parentFile) {
330
+ imports.push(`import parent from ${JSON.stringify(specifier(file, parentFile))};`);
331
+ config.push("parent");
332
+ }
333
+ for (const segment of segments) {
334
+ if (segment.kind !== "static") {
335
+ imported.push("stringParam");
336
+ config.push(`params: { ${JSON.stringify(segment.name)}: stringParam }`);
337
+ }
338
+ }
339
+ imports.unshift(`import { ${imported.join(", ")} } from "@loozo/angular-router";`);
340
+ authored.set(file, `${imports.join("\n")}\n\nexport default defineRoute(${JSON.stringify(path)}, { ${config.join(", ")} });\n`);
341
+ }
342
+ return authored;
343
+ }
@@ -0,0 +1,30 @@
1
+ import { resolve } from "node:path";
2
+ import { discover } from "./sources.js";
3
+ import { selections, validateProgram } from "./validate.js";
4
+ import { emit } from "./emit.js";
5
+ import { auditPath, metadata, planOutputs, publish, walk } from "./files.js";
6
+ export async function generate(workspace, options, stopped) {
7
+ const sources = await discover(workspace, options.tsconfig, options.scanDirectories, options.outDirectory, options.autoDefineRoutes);
8
+ const selected = selections(sources);
9
+ const desired = emit(sources, selected);
10
+ const globalFiles = [
11
+ resolve(sources.output, "routes.generated.ts"),
12
+ resolve(sources.output, "destinations.generated.ts"),
13
+ ];
14
+ await walk(sources.output);
15
+ const plan = await planOutputs(desired, sources.encountered, globalFiles);
16
+ // Unrelated output contents are audited but never become cleanup candidates.
17
+ const seeds = new Set();
18
+ for (const file of sources.authored.keys()) {
19
+ await auditPath(file);
20
+ const info = await metadata(file);
21
+ if (!info) {
22
+ seeds.add(file);
23
+ }
24
+ else if (!info.isFile()) {
25
+ throw new Error(`Filesystem definition is not a regular file: ${file}`);
26
+ }
27
+ }
28
+ await validateProgram(sources, desired, plan.stale);
29
+ publish(plan, options.check === true, stopped, sources.authored, seeds);
30
+ }
@@ -0,0 +1,84 @@
1
+ import ts from "typescript";
2
+ import { readdirSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { contains } from "./files.js";
5
+ // TypeScript exports its native config matcher at runtime, but not in its .d.ts.
6
+ // Use the installed compiler's matcher, including its extension/exclusion rules.
7
+ const { matchFiles } = ts;
8
+ export function overlayHost(options, authored) {
9
+ const host = ts.createCompilerHost(options, true);
10
+ const read = host.readFile.bind(host);
11
+ const exists = host.fileExists.bind(host);
12
+ const directoryExists = host.directoryExists.bind(host);
13
+ host.readFile = (file) => {
14
+ const raw = authored.get(resolve(file));
15
+ if (raw === undefined) {
16
+ return read(file);
17
+ }
18
+ // Match ts.sys.readFile's UTF-8 BOM normalization, including for shebangs.
19
+ // The authored map remains raw publication text, not compiler text.
20
+ if (raw.startsWith("\uFEFF")) {
21
+ return raw.slice(1);
22
+ }
23
+ return raw;
24
+ };
25
+ host.fileExists = (file) => authored.has(resolve(file)) || exists(file);
26
+ host.directoryExists = (directory) => {
27
+ for (const file of authored.keys()) {
28
+ if (contains(resolve(directory), file)) {
29
+ return true;
30
+ }
31
+ }
32
+ return directoryExists(directory);
33
+ };
34
+ host.getSourceFile = (file, language) => {
35
+ const bytes = host.readFile(file);
36
+ if (bytes === undefined) {
37
+ return undefined;
38
+ }
39
+ return ts.createSourceFile(file, bytes, language, true);
40
+ };
41
+ return host;
42
+ }
43
+ export function overlayDirectory(authored, resolveDirectory) {
44
+ if (authored.size === 0) {
45
+ return ts.sys.readDirectory.bind(ts.sys);
46
+ }
47
+ return (root, extensions, excludes, includes, depth) => matchFiles(root, extensions, excludes, includes, ts.sys.useCaseSensitiveFileNames, ts.sys.getCurrentDirectory(), depth, (directory) => {
48
+ const files = new Set();
49
+ const directories = new Set();
50
+ // Lookup only: matchFiles still returns its original path spellings,
51
+ // which discovery canonicalizes/audits before admitting any source.
52
+ const canonicalDirectory = resolveDirectory(directory);
53
+ if (ts.sys.directoryExists(directory)) {
54
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
55
+ if (entry.isDirectory()) {
56
+ directories.add(entry.name);
57
+ }
58
+ else if (entry.isFile()) {
59
+ files.add(entry.name);
60
+ }
61
+ else if (entry.isSymbolicLink()) {
62
+ const path = resolve(directory, entry.name);
63
+ if (ts.sys.directoryExists(path)) {
64
+ directories.add(entry.name);
65
+ }
66
+ else {
67
+ files.add(entry.name);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ for (const file of authored.keys()) {
73
+ if (dirname(file) === canonicalDirectory) {
74
+ files.add(basename(file));
75
+ }
76
+ }
77
+ return { files: [...files].sort(), directories: [...directories].sort() };
78
+ }, (path) => {
79
+ if (ts.sys.fileExists(path) || ts.sys.directoryExists(path)) {
80
+ return ts.sys.realpath(path);
81
+ }
82
+ return resolve(path);
83
+ });
84
+ }
@@ -0,0 +1,60 @@
1
+ import { authoredPath } from "./validate.js";
2
+ // Compile URL-template segment relationships, never authored directory ancestry.
3
+ export function relativeSelections(sources, selected) {
4
+ const result = new Map();
5
+ for (const origin of sources.declarations) {
6
+ if (!origin.retained) {
7
+ continue;
8
+ }
9
+ const expressions = new Map();
10
+ result.set(origin.id, expressions);
11
+ if (origin.effective.some((segment) => segment.kind === "catchall")) {
12
+ continue;
13
+ }
14
+ let originParts = [];
15
+ if (origin.effective.length > 0) {
16
+ originParts = authoredPath(origin).slice(1).split("/");
17
+ }
18
+ for (let climb = 0; climb <= originParts.length; climb += 1) {
19
+ const prefix = originParts.slice(0, originParts.length - climb);
20
+ const steps = [];
21
+ for (let index = 0; index < climb; index += 1) {
22
+ steps.push("..");
23
+ }
24
+ for (const [path, target] of selected) {
25
+ let targetParts = [];
26
+ if (target.effective.length > 0) {
27
+ targetParts = path.slice(1).split("/");
28
+ }
29
+ if (prefix.length > targetParts.length) {
30
+ continue;
31
+ }
32
+ let matches = true;
33
+ for (let index = 0; index < prefix.length; index += 1) {
34
+ if (prefix[index] !== targetParts[index]) {
35
+ matches = false;
36
+ break;
37
+ }
38
+ }
39
+ if (!matches) {
40
+ continue;
41
+ }
42
+ const suffix = targetParts.slice(prefix.length);
43
+ if (suffix.some((part) => part.includes("?") || part.includes("#"))) {
44
+ continue;
45
+ }
46
+ let expression = "./";
47
+ if (climb > 0) {
48
+ expression = steps.join("/");
49
+ if (suffix.length === 0) {
50
+ expressions.set(expression, target.id);
51
+ }
52
+ expression += "/";
53
+ }
54
+ expression += suffix.join("/");
55
+ expressions.set(expression, target.id);
56
+ }
57
+ }
58
+ }
59
+ return result;
60
+ }