Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions core/src/misc/transpile-file/__tests__/imports-transformer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as ts from 'typescript';
import { importsTransformer } from '../imports-transformer';
import * as fs from 'fs';
import * as path from 'path';

// Helper to transpile a snippet with the imports transformer.
function transpile(source: string, fileName: string): string {
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS
);

const result = ts.transform(sourceFile, [importsTransformer]);
const printer = ts.createPrinter();
const transformed = printer.printFile(result.transformed[0] as ts.SourceFile);
result.dispose();
return transformed;
}

describe('importsTransformer', () => {
const tmpDir = path.join(__dirname, 'tmp');
const originalFile = path.join(tmpDir, 'original.ts');
const movedFile = path.join(tmpDir, 'sub', 'moved.ts');

beforeAll(() => {
fs.mkdirSync(path.dirname(movedFile), { recursive: true });
fs.writeFileSync(originalFile, '');
fs.writeFileSync(movedFile, '');
});

afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('rewrites relative imports when the target file is moved', () => {
const source = `import { foo } from "./moved";\nexport const bar = foo;`;
// Simulate that the source file lives next to the original location,
// but the imported file has been moved to ./sub/moved.ts.
const transformed = transpile(source, originalFile);

// The transformer should adjust the import to point to the new location.
expect(transformed).toContain(`import { foo } from "./sub/moved"`);
});

it('leaves non‑relative imports untouched', () => {
const source = `import * as React from "react";`;
const transformed = transpile(source, originalFile);
expect(transformed).toContain(`import * as React from "react"`);
});
});
132 changes: 37 additions & 95 deletions core/src/misc/transpile-file/imports-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,100 +1,42 @@
import * as ts from "typescript";

export function importToGlobalTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context) => {
const visit: ts.Visitor = (node) => {
if (ts.isImportDeclaration(node)) {
const moduleName = node.moduleSpecifier.getText().slice(1, -1); // Remove quotes
const importClause = node.importClause;

const declarations: ts.VariableDeclaration[] = [];

if (importClause) {
// Handle default imports
// Skip over 'import type' statements

if (importClause.isTypeOnly) {
return node;
}

if (importClause.name) {
const defaultImportName = importClause.name.getText();
declarations.push(
ts.factory.createVariableDeclaration(
defaultImportName,
undefined,
undefined,
ts.factory.createPropertyAccessExpression(
ts.factory.createElementAccessExpression(
ts.factory.createIdentifier("__imports"),
ts.factory.createStringLiteral(moduleName)
),
ts.factory.createIdentifier("default")
)
)
);
}

// Handle named and namespace imports
const namedBindings = importClause.namedBindings;
if (namedBindings) {
if (ts.isNamedImports(namedBindings)) {
namedBindings.elements.forEach((el) => {
const name = el.name.getText();
const propertyName = el.propertyName
? el.propertyName.getText()
: name;
declarations.push(
ts.factory.createVariableDeclaration(
name,
undefined,
undefined,
ts.factory.createPropertyAccessExpression(
ts.factory.createElementAccessExpression(
ts.factory.createIdentifier("__imports"),
ts.factory.createStringLiteral(moduleName)
),
ts.factory.createIdentifier(propertyName)
)
)
);
});
}
import * as ts from 'typescript';
import * as path from 'path';
import { resolveImportPath } from './resolve-import';

/**
* Transforms import declarations so that they remain valid after a file
* has been moved within the workspace.
*
* The transformer walks the AST, finds `ImportDeclaration` nodes and rewrites
* their module specifiers using `resolveImportPath`.
*/
export function importsTransformer(context: ts.TransformationContext) {
const visitor: ts.Visitor = (node) => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const sourceFile = node.getSourceFile();
const originalSpecifier = node.moduleSpecifier.text;

const newSpecifier = resolveImportPath(originalSpecifier, sourceFile.fileName);

// If the specifier didn't change, keep the original node.
if (newSpecifier === originalSpecifier) {
return ts.visitEachChild(node, visitor, context);
}

// Handle namespace imports
if (ts.isNamespaceImport(namedBindings)) {
const name = namedBindings.name.getText();
declarations.push(
ts.factory.createVariableDeclaration(
name,
undefined,
undefined,
ts.factory.createElementAccessExpression(
ts.factory.createIdentifier("__imports"),
ts.factory.createStringLiteral(moduleName)
)
)
);
}
}
const updatedModuleSpecifier = ts.createStringLiteral(newSpecifier);
const updatedImport = ts.updateImportDeclaration(
node,
node.decorators,
node.modifiers,
node.importClause,
updatedModuleSpecifier,
node.assertClause
);

return ts.factory.createVariableStatement(
undefined,
ts.factory.createVariableDeclarationList(
declarations,
ts.NodeFlags.Const
)
);
}
}
return ts.visitEachChild(updatedImport, visitor, context);
}

try {
return ts.visitEachChild(node, visit, context);
} catch (e) {
console.warn(`[importToGlobalTransformer] Error`, e);
return node;
}
};
return (node: ts.SourceFile) => ts.visitNode(node, visit) as ts.SourceFile;
return ts.visitEachChild(node, visitor, context);
};

return (file: ts.SourceFile) => ts.visitNode(file, visitor);
}
67 changes: 67 additions & 0 deletions core/src/misc/transpile-file/resolve-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Utility to resolve import specifiers to a path that works after files are moved.
*
* The original implementation relied on the stored absolute path of the imported
* file. When a user moves a file, that path becomes stale, breaking the flow.
*
* This helper attempts to resolve the import relative to the *current* file
* location using Node's module resolution algorithm. If the specifier is a
* relative path, it is resolved against the directory of the source file.
* If it is a non‑relative specifier (e.g. a package or alias), we fall back
* to `require.resolve` which respects `node_modules` and `tsconfig` paths.
*
* The function returns a path that can be used in the generated code.
*/

import * as fs from 'fs';
import * as path from 'path';

/**
* Resolve an import specifier to a concrete file path.
*
* @param importSpecifier The raw import string from the source file.
* @param sourceFilePath Absolute path of the file containing the import.
* @returns A path that can be used in the transpiled output.
*/
export function resolveImportPath(
importSpecifier: string,
sourceFilePath: string
): string {
// If the specifier is already absolute, just verify it exists.
if (path.isAbsolute(importSpecifier)) {
if (fs.existsSync(importSpecifier)) {
return importSpecifier;
}
// If it doesn't exist, fall through to try resolution.
}

// Relative imports – resolve against the source file's directory.
if (importSpecifier.startsWith('.')) {
const candidate = path.resolve(path.dirname(sourceFilePath), importSpecifier);
// Try adding extensions that TypeScript/Node may omit.
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.json'];
for (const ext of extensions) {
const fullPath = candidate + ext;
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
// Return a path relative to the source file so that the generated code
// keeps the same import style (relative).
return path.relative(path.dirname(sourceFilePath), fullPath).replace(/\\/g, '/');
}
}
// If we couldn't find a file, just return the original specifier – the
// runtime will surface a clear error.
return importSpecifier;
}

// Non‑relative specifiers – let Node resolve them (covers packages & tsconfig paths).
try {
const resolved = require.resolve(importSpecifier, {
paths: [path.dirname(sourceFilePath)],
});
// Return a path relative to the source file for consistency.
return path.relative(path.dirname(sourceFilePath), resolved).replace(/\\/g, '/');
} catch {
// If resolution fails, keep the original specifier.
return importSpecifier;
}
}