Files
tsbuild/ts/mod_pathrewrite/classes.tspathrewriter.ts
Juergen Kunz 4ccb7ea9d6 fix(TsPathRewriter): rewrite cross-module import paths after compilation
When compiling multiple ts_* source directories to dist_ts_* outputs,
imports like '../ts_shared/helper.js' now correctly become
'../dist_ts_shared/helper.js' in the compiled output.

Uses targeted regex patterns that only match import/export/require
statements, avoiding false positives in strings or comments.
2026-01-12 17:48:04 +00:00

201 lines
6.0 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { FsHelpers } from '../mod_fs/index.js';
/**
* Interface for folder mapping between source and destination
*/
export interface IFolderMapping {
sourceFolder: string; // e.g., 'ts_shared'
destFolder: string; // e.g., 'dist_ts_shared'
}
/**
* TsPathRewriter handles rewriting import paths in compiled JavaScript files.
*
* When TypeScript compiles files that import from sibling directories like:
* import { helper } from '../ts_shared/helper.js';
*
* This class rewrites them to point to the compiled output directories:
* import { helper } from '../dist_ts_shared/helper.js';
*
* This is necessary because the unpack feature flattens nested output structures,
* changing the relative paths between modules.
*/
export class TsPathRewriter {
private mappings: IFolderMapping[];
constructor(mappings: IFolderMapping[]) {
this.mappings = mappings;
}
/**
* Create a TsPathRewriter from glob patterns used in compilation
*
* @param globPatterns - Map of source patterns to destination directories
* e.g., { './ts_core/**\/*.ts': './dist_ts_core', './ts_shared/**\/*.ts': './dist_ts_shared' }
* @returns TsPathRewriter instance with extracted folder mappings
*/
public static fromGlobPatterns(globPatterns: Record<string, string>): TsPathRewriter {
const mappings: IFolderMapping[] = [];
for (const [sourcePattern, destDir] of Object.entries(globPatterns)) {
const sourceFolder = FsHelpers.extractSourceFolder(sourcePattern);
const destFolder = FsHelpers.extractSourceFolder(destDir);
if (sourceFolder && destFolder) {
mappings.push({ sourceFolder, destFolder });
}
}
return new TsPathRewriter(mappings);
}
/**
* Get the current folder mappings
*/
public getMappings(): IFolderMapping[] {
return [...this.mappings];
}
/**
* Rewrite import paths in a single file
*
* @param filePath - Absolute path to the file to rewrite
* @returns true if file was modified, false otherwise
*/
public async rewriteFile(filePath: string): Promise<boolean> {
// Only process .js and .d.ts files
if (!filePath.endsWith('.js') && !filePath.endsWith('.d.ts')) {
return false;
}
try {
const content = await fs.promises.readFile(filePath, 'utf8');
const rewritten = this.rewriteContent(content);
if (rewritten !== content) {
await fs.promises.writeFile(filePath, rewritten, 'utf8');
return true;
}
return false;
} catch (error) {
// File doesn't exist or other error
return false;
}
}
/**
* Rewrite import paths in all .js and .d.ts files in a directory (recursively)
*
* @param dirPath - Absolute path to the directory
* @returns Number of files modified
*/
public async rewriteDirectory(dirPath: string): Promise<number> {
let modifiedCount = 0;
const processDir = async (currentDir: string): Promise<void> => {
const entries = await FsHelpers.listDirectory(currentDir);
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory) {
await processDir(fullPath);
} else if (entry.isFile && (entry.name.endsWith('.js') || entry.name.endsWith('.d.ts'))) {
const wasModified = await this.rewriteFile(fullPath);
if (wasModified) {
modifiedCount++;
}
}
}
};
if (await FsHelpers.directoryExists(dirPath)) {
await processDir(dirPath);
}
return modifiedCount;
}
/**
* Rewrite import paths in content string
*
* This method only modifies actual import/export/require statements,
* not arbitrary strings or comments that might contain similar patterns.
*
* @param content - File content to process
* @returns Rewritten content
*/
public rewriteContent(content: string): string {
if (this.mappings.length === 0) {
return content;
}
let result = content;
for (const { sourceFolder, destFolder } of this.mappings) {
// Skip if source and dest are the same
if (sourceFolder === destFolder) {
continue;
}
// Pattern 1: ES Module imports/exports with 'from'
// Matches: from '../ts_shared/...' or from '../../ts_shared/...'
// Also handles: export { x } from '../ts_shared/...'
const fromPattern = new RegExp(
`(from\\s*['"])((?:\\.\\.\\/)+)${this.escapeRegex(sourceFolder)}(\\/[^'"]*['"])`,
'g'
);
result = result.replace(fromPattern, `$1$2${destFolder}$3`);
// Pattern 2: Dynamic imports
// Matches: import('../ts_shared/...')
const dynamicImportPattern = new RegExp(
`(import\\s*\\(\\s*['"])((?:\\.\\.\\/)+)${this.escapeRegex(sourceFolder)}(\\/[^'"]*['"]\\s*\\))`,
'g'
);
result = result.replace(dynamicImportPattern, `$1$2${destFolder}$3`);
// Pattern 3: CommonJS require
// Matches: require('../ts_shared/...')
const requirePattern = new RegExp(
`(require\\s*\\(\\s*['"])((?:\\.\\.\\/)+)${this.escapeRegex(sourceFolder)}(\\/[^'"]*['"]\\s*\\))`,
'g'
);
result = result.replace(requirePattern, `$1$2${destFolder}$3`);
}
return result;
}
/**
* Escape special regex characters in a string
*/
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
/**
* Convenience function to rewrite import paths in output directories
*
* @param globPatterns - Map of source patterns to destination directories
* @param outputDirs - List of output directories to process
* @returns Total number of files modified
*/
export async function rewriteImportPaths(
globPatterns: Record<string, string>,
outputDirs: string[]
): Promise<number> {
const rewriter = TsPathRewriter.fromGlobPatterns(globPatterns);
let totalModified = 0;
for (const dir of outputDirs) {
totalModified += await rewriter.rewriteDirectory(dir);
}
return totalModified;
}