@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.
@@ -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 = await access.resolve(config);
180
+ const configFile = access.resolve(config);
176
181
  const configDirectory = dirname(configFile);
177
- const output = await access.resolve(out);
178
- const resolvedRoots = await Promise.all(scans.map((scan) => access.resolve(resolve(configDirectory, scan))));
179
- const roots = [...new Set(resolvedRoots)].sort();
180
- for (const root of roots) {
181
- if (contains(root, output) || contains(output, root)) {
182
- throw new Error(`Output and scan directory overlap: ${output}; ${root}`);
183
- }
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
- const candidates = [];
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
- const normalized = await access.resolve(file);
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 (roots.some((root) => contains(root, normalized))) {
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: ts.createCompilerHost(parsed.options, true),
242
+ host: syntaxHost,
229
243
  });
230
244
  for (const file of candidates) {
231
- const bytes = await readFile(file, "utf8");
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
- if (needsParams !== config.has("params")) {
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 = ts.createCompilerHost(sources.parsed.options);
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);