@loozo/angular-router 0.0.1 → 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.
- package/README.md +38 -9
- package/fesm2022/loozo-angular-router-_core.mjs +68 -0
- package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
- package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
- package/fesm2022/loozo-angular-router.mjs +13 -0
- package/fesm2022/loozo-angular-router.mjs.map +1 -1
- package/node/src/builders/options.js +29 -3
- package/node/src/builders/serve.js +2 -2
- package/node/src/generation/emit.js +26 -1
- package/node/src/generation/files.js +28 -7
- package/node/src/generation/filesystem.js +343 -0
- package/node/src/generation/generate.js +14 -3
- package/node/src/generation/overlay.js +84 -0
- package/node/src/generation/sources.js +64 -23
- package/node/src/generation/validate.js +16 -1
- package/package.json +1 -1
- package/schemas/application.json +23 -0
- package/schemas/serve.json +23 -0
- package/types/loozo-angular-router-_core.d.ts +299 -0
- package/types/loozo-angular-router-_generated.d.ts +115 -0
|
@@ -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
|
+
}
|
|
@@ -2,9 +2,9 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { discover } from "./sources.js";
|
|
3
3
|
import { selections, validateProgram } from "./validate.js";
|
|
4
4
|
import { emit } from "./emit.js";
|
|
5
|
-
import { planOutputs, publish, walk } from "./files.js";
|
|
5
|
+
import { auditPath, metadata, planOutputs, publish, walk } from "./files.js";
|
|
6
6
|
export async function generate(workspace, options, stopped) {
|
|
7
|
-
const sources = await discover(workspace, options.tsconfig, options.scanDirectories, options.outDirectory);
|
|
7
|
+
const sources = await discover(workspace, options.tsconfig, options.scanDirectories, options.outDirectory, options.autoDefineRoutes);
|
|
8
8
|
const selected = selections(sources);
|
|
9
9
|
const desired = emit(sources, selected);
|
|
10
10
|
const globalFiles = [
|
|
@@ -14,6 +14,17 @@ export async function generate(workspace, options, stopped) {
|
|
|
14
14
|
await walk(sources.output);
|
|
15
15
|
const plan = await planOutputs(desired, sources.encountered, globalFiles);
|
|
16
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
|
+
}
|
|
17
28
|
await validateProgram(sources, desired, plan.stale);
|
|
18
|
-
publish(plan, options.check === true, stopped);
|
|
29
|
+
publish(plan, options.check === true, stopped, sources.authored, seeds);
|
|
19
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
|
+
}
|
|
@@ -4,6 +4,8 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parsePath } from "../../_core/src/path.js";
|
|
6
6
|
import { auditPath, contains, metadata, walk, workspacePaths, markers } from "./files.js";
|
|
7
|
+
import { overlayDirectory, overlayHost } from "./overlay.js";
|
|
8
|
+
import { synchronize, transparent } from "./filesystem.js";
|
|
7
9
|
export function fail(node, message) {
|
|
8
10
|
const source = node.getSourceFile();
|
|
9
11
|
const position = source.getLineAndCharacterOfPosition(node.getStart());
|
|
@@ -88,8 +90,8 @@ function moduleText(statement) {
|
|
|
88
90
|
}
|
|
89
91
|
return statement.moduleSpecifier.text;
|
|
90
92
|
}
|
|
91
|
-
async function genuineImport(statement, expected, options) {
|
|
92
|
-
const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options);
|
|
93
|
+
async function genuineImport(statement, expected, options, host = ts.sys) {
|
|
94
|
+
const actual = resolveModule(moduleText(statement), statement.getSourceFile().fileName, options, host);
|
|
93
95
|
if (!actual) {
|
|
94
96
|
return false;
|
|
95
97
|
}
|
|
@@ -170,25 +172,25 @@ const keys = new Set([
|
|
|
170
172
|
"title",
|
|
171
173
|
"data",
|
|
172
174
|
]);
|
|
173
|
-
export async function discover(workspace, config, scans, out) {
|
|
175
|
+
export async function discover(workspace, config, scans, out, auto, authored = new Map(), filesystem = auto !== undefined) {
|
|
176
|
+
if (scans.length !== 1) {
|
|
177
|
+
throw new Error("scanDirectories must contain exactly one directory");
|
|
178
|
+
}
|
|
174
179
|
const access = await workspacePaths(workspace);
|
|
175
|
-
const configFile =
|
|
180
|
+
const configFile = access.resolve(config);
|
|
176
181
|
const configDirectory = dirname(configFile);
|
|
177
|
-
const output =
|
|
178
|
-
const
|
|
179
|
-
const roots = [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
throw new Error(`Output and scan directory overlap: ${output}; ${root}`);
|
|
183
|
-
}
|
|
182
|
+
const output = access.resolve(out);
|
|
183
|
+
const root = access.resolve(resolve(configDirectory, scans[0]));
|
|
184
|
+
const roots = [root];
|
|
185
|
+
if (contains(root, output) || contains(output, root)) {
|
|
186
|
+
throw new Error(`Output and scan directory overlap: ${output}; ${root}`);
|
|
184
187
|
}
|
|
185
188
|
await Promise.all([auditPath(configFile), auditPath(output)]);
|
|
186
|
-
const encountered =
|
|
187
|
-
...new Set((await Promise.all(roots.map((root) => walk(root)))).flat()),
|
|
188
|
-
].sort();
|
|
189
|
+
const encountered = (await walk(root)).sort();
|
|
189
190
|
const fatal = [];
|
|
190
191
|
const parsed = ts.getParsedCommandLineOfConfigFile(configFile, {}, {
|
|
191
192
|
...ts.sys,
|
|
193
|
+
readDirectory: overlayDirectory(authored, (directory) => access.resolve(directory, true)),
|
|
192
194
|
onUnRecoverableConfigFileDiagnostic: (diagnostic) => fatal.push(diagnostic),
|
|
193
195
|
});
|
|
194
196
|
const errors = [...fatal, ...(parsed?.errors ?? [])].filter((error) => error.code !== 18003);
|
|
@@ -201,16 +203,27 @@ export async function discover(workspace, config, scans, out) {
|
|
|
201
203
|
getNewLine: () => "\n",
|
|
202
204
|
}));
|
|
203
205
|
}
|
|
204
|
-
|
|
206
|
+
// Canonicalize only permitted workspace-access prefixes, never source links.
|
|
207
|
+
// Membership, candidate discovery and the final program share these roots.
|
|
208
|
+
const admitted = new Set();
|
|
205
209
|
for (const file of parsed.fileNames) {
|
|
206
|
-
|
|
210
|
+
admitted.add(access.resolve(file));
|
|
211
|
+
}
|
|
212
|
+
parsed.fileNames = [...admitted];
|
|
213
|
+
for (const file of authored.keys()) {
|
|
214
|
+
if (!admitted.has(file)) {
|
|
215
|
+
throw new Error(`TypeScript config does not admit filesystem definition: ${file}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const candidates = [];
|
|
219
|
+
for (const normalized of parsed.fileNames) {
|
|
207
220
|
if (!normalized.endsWith(".ts") ||
|
|
208
221
|
normalized.endsWith(".d.ts") ||
|
|
209
222
|
normalized.split(sep).includes("node_modules")) {
|
|
210
223
|
continue;
|
|
211
224
|
}
|
|
212
225
|
await auditPath(normalized);
|
|
213
|
-
if (
|
|
226
|
+
if (contains(root, normalized)) {
|
|
214
227
|
candidates.push(normalized);
|
|
215
228
|
}
|
|
216
229
|
}
|
|
@@ -222,13 +235,21 @@ export async function discover(workspace, config, scans, out) {
|
|
|
222
235
|
const declarations = [];
|
|
223
236
|
const pages = new Map();
|
|
224
237
|
const deferredFactories = [];
|
|
238
|
+
const syntaxHost = overlayHost(parsed.options, authored);
|
|
225
239
|
const syntaxProgram = ts.createProgram({
|
|
226
240
|
rootNames: candidates,
|
|
227
241
|
options: parsed.options,
|
|
228
|
-
host:
|
|
242
|
+
host: syntaxHost,
|
|
229
243
|
});
|
|
230
244
|
for (const file of candidates) {
|
|
231
|
-
const bytes =
|
|
245
|
+
const bytes = syntaxHost.readFile(file);
|
|
246
|
+
if (bytes === undefined) {
|
|
247
|
+
if (auto) {
|
|
248
|
+
// An explicit tsconfig files entry may name a not-yet-seeded definition.
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Cannot read admitted TypeScript source: ${file}`);
|
|
252
|
+
}
|
|
232
253
|
if (file.endsWith(`${sep}route.generated.ts`) &&
|
|
233
254
|
bytes.split("\n", 1)[0] === markers["route.generated.ts"]) {
|
|
234
255
|
continue;
|
|
@@ -294,7 +315,8 @@ export async function discover(workspace, config, scans, out) {
|
|
|
294
315
|
fail(path, String(error));
|
|
295
316
|
}
|
|
296
317
|
const needsParams = segments.some((segment) => segment.kind !== "static");
|
|
297
|
-
|
|
318
|
+
const ignored = filesystem && transparent(roots[0], dirname(file));
|
|
319
|
+
if (!auto && !ignored && needsParams !== config.has("params")) {
|
|
298
320
|
fail(configNode, "Local params must be present exactly when the path declares placeholders");
|
|
299
321
|
}
|
|
300
322
|
const data = config.get("data");
|
|
@@ -316,6 +338,7 @@ export async function discover(workspace, config, scans, out) {
|
|
|
316
338
|
file,
|
|
317
339
|
id,
|
|
318
340
|
source,
|
|
341
|
+
rawText: authored.get(resolve(file)) ?? (await readFile(file, "utf8")),
|
|
319
342
|
config,
|
|
320
343
|
path: path.text,
|
|
321
344
|
segments,
|
|
@@ -325,19 +348,35 @@ export async function discover(workspace, config, scans, out) {
|
|
|
325
348
|
full: false,
|
|
326
349
|
};
|
|
327
350
|
const ownFacade = resolve(dirname(file), "route.generated.ts");
|
|
328
|
-
const resolved = resolveModule(moduleText(imported), file, parsed.options);
|
|
351
|
+
const resolved = resolveModule(moduleText(imported), file, parsed.options, syntaxHost);
|
|
329
352
|
const facadeSpelling = resolve(dirname(file), moduleText(imported)).replace(/\.js$/, ".ts");
|
|
330
353
|
if (resolved === ownFacade || (!resolved && facadeSpelling === ownFacade)) {
|
|
331
354
|
deferredFactories.push(declaration);
|
|
332
355
|
}
|
|
333
356
|
else {
|
|
334
|
-
const genuine = await genuineImport(imported, factoryEntry, parsed.options);
|
|
357
|
+
const genuine = await genuineImport(imported, factoryEntry, parsed.options, syntaxHost);
|
|
335
358
|
if (!genuine) {
|
|
336
359
|
fail(imported, "defineRoute import does not resolve to this package's concrete public entry");
|
|
337
360
|
}
|
|
338
361
|
}
|
|
339
362
|
declarations.push(declaration);
|
|
340
363
|
}
|
|
364
|
+
if (auto) {
|
|
365
|
+
const edits = await synchronize(roots[0], declarations, pages, parsed.options, auto, syntaxProgram.getTypeChecker());
|
|
366
|
+
return discover(workspace, config, scans, out, undefined, edits, true);
|
|
367
|
+
}
|
|
368
|
+
if (filesystem) {
|
|
369
|
+
for (let index = declarations.length - 1; index >= 0; index -= 1) {
|
|
370
|
+
const declaration = declarations[index];
|
|
371
|
+
if (transparent(roots[0], dirname(declaration.file))) {
|
|
372
|
+
declarations.splice(index, 1);
|
|
373
|
+
const deferred = deferredFactories.indexOf(declaration);
|
|
374
|
+
if (deferred >= 0) {
|
|
375
|
+
deferredFactories.splice(deferred, 1);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
341
380
|
const byDirectory = new Map();
|
|
342
381
|
const byFile = new Map();
|
|
343
382
|
for (const declaration of declarations) {
|
|
@@ -365,7 +404,7 @@ export async function discover(workspace, config, scans, out) {
|
|
|
365
404
|
statement.importClause?.name?.text !== node.text) {
|
|
366
405
|
continue;
|
|
367
406
|
}
|
|
368
|
-
const file = resolveModule(moduleText(statement), declaration.file, compilerOptions);
|
|
407
|
+
const file = resolveModule(moduleText(statement), declaration.file, compilerOptions, syntaxHost);
|
|
369
408
|
const target = file && byFile.get(file);
|
|
370
409
|
if (target) {
|
|
371
410
|
return target;
|
|
@@ -489,5 +528,7 @@ export async function discover(workspace, config, scans, out) {
|
|
|
489
528
|
declarations,
|
|
490
529
|
factoryEntry,
|
|
491
530
|
deferredFactories,
|
|
531
|
+
authored,
|
|
532
|
+
filesystem,
|
|
492
533
|
};
|
|
493
534
|
}
|
|
@@ -3,6 +3,7 @@ import { realpath } from "node:fs/promises";
|
|
|
3
3
|
import { basename, dirname, resolve, sep } from "node:path";
|
|
4
4
|
import { auditPath, contains } from "./files.js";
|
|
5
5
|
import { fail, properties, resolveModule } from "./sources.js";
|
|
6
|
+
import { overlayHost } from "./overlay.js";
|
|
6
7
|
export function authoredPath(declaration) {
|
|
7
8
|
const parts = [];
|
|
8
9
|
for (const segment of declaration.effective) {
|
|
@@ -112,7 +113,7 @@ export function selections(sources) {
|
|
|
112
113
|
}
|
|
113
114
|
// Overlay only the final generated locations. Never evaluate application code.
|
|
114
115
|
export async function validateProgram(sources, generated, stale) {
|
|
115
|
-
const host =
|
|
116
|
+
const host = overlayHost(sources.parsed.options, sources.authored);
|
|
116
117
|
const originalRead = host.readFile.bind(host);
|
|
117
118
|
const originalExists = host.fileExists.bind(host);
|
|
118
119
|
const originalDirectoryExists = host.directoryExists.bind(host);
|
|
@@ -148,6 +149,20 @@ export async function validateProgram(sources, generated, stale) {
|
|
|
148
149
|
options: sources.parsed.options,
|
|
149
150
|
host,
|
|
150
151
|
});
|
|
152
|
+
if (sources.filesystem) {
|
|
153
|
+
const diagnostics = [];
|
|
154
|
+
for (const declaration of sources.declarations) {
|
|
155
|
+
const source = program.getSourceFile(declaration.file);
|
|
156
|
+
diagnostics.push(...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source));
|
|
157
|
+
}
|
|
158
|
+
if (diagnostics.length > 0) {
|
|
159
|
+
throw new Error(ts.formatDiagnostics(diagnostics, {
|
|
160
|
+
getCanonicalFileName: (name) => name,
|
|
161
|
+
getCurrentDirectory: () => dirname(sources.configFile),
|
|
162
|
+
getNewLine: () => "\n",
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
151
166
|
const checker = program.getTypeChecker();
|
|
152
167
|
const entry = program.getSourceFile(sources.factoryEntry);
|
|
153
168
|
const entryModule = checker.getSymbolAtLocation(entry);
|