|
| 1 | +import path from "path" |
| 2 | + |
| 3 | +import fs from "fs-extra" |
| 4 | +import * as ts from "typescript" |
| 5 | + |
| 6 | +/** |
| 7 | + * Read and parse tsconfig, honoring "extends". Returns compiler options usable for module resolution. |
| 8 | + */ |
| 9 | +export function readTsCompilerOptions(tsconfigPath?: string) { |
| 10 | + if (!tsconfigPath) { |
| 11 | + const options = ts.getDefaultCompilerOptions() |
| 12 | + if (!options.moduleResolution) options.moduleResolution = ts.ModuleResolutionKind.NodeJs |
| 13 | + return { options, files: [] as string[] } |
| 14 | + } |
| 15 | + |
| 16 | + const configFile = ts.readJsonConfigFile(tsconfigPath, f => fs.readFileSync(f, "utf8")) |
| 17 | + const { options, fileNames, errors } = ts.parseJsonSourceFileConfigFileContent( |
| 18 | + configFile, |
| 19 | + ts.sys, |
| 20 | + path.dirname(tsconfigPath) |
| 21 | + ) |
| 22 | + |
| 23 | + if (!options.moduleResolution) options.moduleResolution = ts.ModuleResolutionKind.NodeJs |
| 24 | + |
| 25 | + if (errors.length) { |
| 26 | + // Keep this utility logger-agnostic. Consumers can log if needed. |
| 27 | + } |
| 28 | + |
| 29 | + return { options, files: fileNames } |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Create a CompilerHost with normalized canonical file names (helps with path comparisons). |
| 34 | + */ |
| 35 | +export function createCompilerHost(options: ts.CompilerOptions) { |
| 36 | + const host = ts.createCompilerHost(options) |
| 37 | + const origGetCanonicalFileName = host.getCanonicalFileName.bind(host) |
| 38 | + host.getCanonicalFileName = f => path.normalize(origGetCanonicalFileName(f)) |
| 39 | + return host |
| 40 | +} |
| 41 | + |
| 42 | +function isStringLiteralLike(node: ts.Node): node is ts.StringLiteralLike { |
| 43 | + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) |
| 44 | +} |
| 45 | + |
| 46 | +/** |
| 47 | + * Collect all module specifiers from a source file: |
| 48 | + * - import ... from 'x' |
| 49 | + * - export ... from 'x' |
| 50 | + * - require('x') |
| 51 | + * - import('x') |
| 52 | + */ |
| 53 | +function collectModuleSpecifiers(sf: ts.SourceFile): string[] { |
| 54 | + const specs: string[] = [] |
| 55 | + |
| 56 | + function visit(node: ts.Node) { |
| 57 | + // import ... from 'x' |
| 58 | + if ( |
| 59 | + ts.isImportDeclaration(node) && |
| 60 | + node.moduleSpecifier && |
| 61 | + isStringLiteralLike(node.moduleSpecifier) |
| 62 | + ) { |
| 63 | + specs.push(node.moduleSpecifier.text) |
| 64 | + } |
| 65 | + |
| 66 | + // export ... from 'x' |
| 67 | + if ( |
| 68 | + ts.isExportDeclaration(node) && |
| 69 | + node.moduleSpecifier && |
| 70 | + isStringLiteralLike(node.moduleSpecifier) |
| 71 | + ) { |
| 72 | + specs.push(node.moduleSpecifier.text) |
| 73 | + } |
| 74 | + |
| 75 | + // require('x') |
| 76 | + if ( |
| 77 | + ts.isCallExpression(node) && |
| 78 | + ts.isIdentifier(node.expression) && |
| 79 | + node.expression.text === "require" && |
| 80 | + node.arguments.length === 1 && |
| 81 | + isStringLiteralLike(node.arguments[0]) |
| 82 | + ) { |
| 83 | + specs.push(node.arguments[0].text) |
| 84 | + } |
| 85 | + |
| 86 | + // import('x') |
| 87 | + if ( |
| 88 | + ts.isCallExpression(node) && |
| 89 | + node.expression.kind === ts.SyntaxKind.ImportKeyword && |
| 90 | + node.arguments.length === 1 && |
| 91 | + isStringLiteralLike(node.arguments[0]) |
| 92 | + ) { |
| 93 | + specs.push(node.arguments[0].text) |
| 94 | + } |
| 95 | + |
| 96 | + ts.forEachChild(node, visit) |
| 97 | + } |
| 98 | + |
| 99 | + visit(sf) |
| 100 | + return specs |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Resolve a module specifier to an absolute file path using TypeScript's module resolution. |
| 105 | + */ |
| 106 | +function resolveModule( |
| 107 | + specifier: string, |
| 108 | + containingFile: string, |
| 109 | + options: ts.CompilerOptions, |
| 110 | + host: ts.ModuleResolutionHost |
| 111 | +): string | undefined { |
| 112 | + const resolved = ts.resolveModuleName(specifier, containingFile, options, host) |
| 113 | + const primary = resolved.resolvedModule?.resolvedFileName |
| 114 | + if (!primary) return undefined |
| 115 | + return path.normalize(primary) |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * Analyze a given source file for imports that resolve under the given target directory. |
| 120 | + * - Skips .d.ts files. |
| 121 | + * - Honors baseUrl/paths from tsconfig. |
| 122 | + */ |
| 123 | +export async function analyzeModelImports( |
| 124 | + sourceFile: string, |
| 125 | + targetDir: string, |
| 126 | + tsconfigPath?: string |
| 127 | +): Promise<string[]> { |
| 128 | + const { options } = readTsCompilerOptions(tsconfigPath) |
| 129 | + const host = createCompilerHost(options) |
| 130 | + |
| 131 | + const program = ts.createProgram([sourceFile], options, host) |
| 132 | + const sf = program.getSourceFile(sourceFile) |
| 133 | + if (!sf) { |
| 134 | + return [] |
| 135 | + } |
| 136 | + |
| 137 | + const specs = collectModuleSpecifiers(sf) |
| 138 | + |
| 139 | + const normalizedTarget = path.normalize(targetDir) |
| 140 | + const hits = new Set<string>() |
| 141 | + for (const spec of specs) { |
| 142 | + const resolved = resolveModule(spec, sourceFile, options, host) |
| 143 | + if (!resolved) continue |
| 144 | + if (resolved.endsWith(".d.ts")) continue // skip .d.ts as requested |
| 145 | + if (resolved.toLowerCase().startsWith(normalizedTarget.toLowerCase() + path.sep)) { |
| 146 | + hits.add(resolved) |
| 147 | + } |
| 148 | + } |
| 149 | + return Array.from(hits) |
| 150 | +} |
| 151 | + |
| 152 | +/** |
| 153 | + * Convert absolute file paths to side-effect import statements relative to a package root. |
| 154 | + * - For index.(ts|tsx|js), imports the folder. |
| 155 | + * - For other files, strips the extension. |
| 156 | + */ |
| 157 | +export function toVirtualEntryImports(resolvedFiles: string[], packageRoot: string): string[] { |
| 158 | + const uniq = new Set<string>() |
| 159 | + for (const abs of resolvedFiles) { |
| 160 | + let rel = path.relative(packageRoot, abs).replace(/\\/g, "/") |
| 161 | + if (/(^|\/)index\.(ts|tsx|js)$/.test(rel)) { |
| 162 | + rel = rel.replace(/\/index\.(ts|tsx|js)$/, "") |
| 163 | + } else { |
| 164 | + rel = rel.replace(/\.(ts|tsx|js)$/, "") |
| 165 | + } |
| 166 | + const spec = `./${rel}` |
| 167 | + uniq.add(`import '${spec}';`) |
| 168 | + } |
| 169 | + return Array.from(uniq) |
| 170 | +} |
0 commit comments