import type { Plugin } from "rollup"; import ts from "typescript"; import fs from "node:fs"; import path from "node:path"; interface IModuleSpecifierRange { end: number; start: number; value: string; } export interface INormalizeDeclarationImportsResult { files: number; replacements: number; } interface INormalizeDeclarationImportsPluginOptions { builds: number; directory: string; } const declarationExtension = ".d.mts"; const codeExtensions = [ ".cjs", ".cts", ".d.ts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx" ]; const packageAliasPrefix = "#package/"; const isRelativeSpecifier = (specifier: string): boolean => /^\.{1,2}\//.test(specifier); const isPackageAliasSpecifier = (specifier: string): boolean => specifier.startsWith(packageAliasPrefix); const getFiles = (directory: string): string[] => { // eslint-disable-next-line security/detect-non-literal-fs-filename return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const entryPath = path.join(directory, entry.name); return entry.isDirectory() ? getFiles(entryPath) : [ entryPath ]; }); }; const getModuleSpecifierRanges = (filePath: string, source: string): IModuleSpecifierRange[] => { const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const ranges: IModuleSpecifierRange[] = []; const addStringLiteral = (node: ts.StringLiteralLike | undefined): void => { if (!node || (!isRelativeSpecifier(node.text) && !isPackageAliasSpecifier(node.text))) { return; } ranges.push({ start: node.getStart(sourceFile) + 1, end: node.getEnd() - 1, value: node.text, }); }; const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { addStringLiteral(node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ? node.moduleSpecifier : undefined); } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && node.moduleReference.expression && ts.isStringLiteralLike(node.moduleReference.expression)) { addStringLiteral(node.moduleReference.expression); } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { addStringLiteral(node.argument.literal); } ts.forEachChild(node, visit); }; visit(sourceFile); return ranges; }; const isWithinDirectory = (directory: string, target: string): boolean => { const relativePath = path.relative(directory, target); return relativePath !== "" && !relativePath.startsWith(`..${path.sep}`) && relativePath !== ".."; }; const getDeclarationCandidates = (directory: string, filePath: string, specifier: string): string[] => { const targetPath = isPackageAliasSpecifier(specifier) ? path.resolve(directory, specifier.slice(packageAliasPrefix.length)) : path.resolve(path.dirname(filePath), specifier); const matchedExtension = codeExtensions.find((extension) => specifier.endsWith(extension)); if (specifier.endsWith(declarationExtension)) { return [ targetPath ]; } if (matchedExtension) { return [ `${targetPath.slice(0, -matchedExtension.length)}${declarationExtension}` ]; } if (path.posix.extname(specifier)) { return []; } return [ `${targetPath}${declarationExtension}`, path.join(targetPath, `index${declarationExtension}`) ]; }; const getNormalizedSpecifier = (directory: string, filePath: string, specifier: string): string | null => { const candidates = getDeclarationCandidates(directory, filePath, specifier) .filter((candidate) => isWithinDirectory(directory, candidate)) // eslint-disable-next-line security/detect-non-literal-fs-filename .filter((candidate) => fs.existsSync(candidate)); if (candidates.length > 1) { throw new Error(`Ambiguous declaration import "${specifier}" in "${filePath}".`); } if (!candidates.length) { if (!path.posix.extname(specifier) || codeExtensions.some((extension) => specifier.endsWith(extension))) { throw new Error(`Unresolved declaration import "${specifier}" in "${filePath}".`); } return null; } const relativePath = path.relative(path.dirname(filePath), candidates[0]).replaceAll(path.sep, "/"); return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; }; /** * */ /** * Rewrites relative declaration imports to exact `.d.mts` targets. * @returns Number of inspected files and rewritten module specifiers. */ export const normalizeDeclarationImports = (directory: string): INormalizeDeclarationImportsResult => { const declarationFiles = getFiles(directory).filter((filePath) => filePath.endsWith(declarationExtension)); let replacements = 0; declarationFiles.forEach((filePath) => { // eslint-disable-next-line security/detect-non-literal-fs-filename const source = fs.readFileSync(filePath, "utf8"); const ranges = getModuleSpecifierRanges(filePath, source); let output = source; ranges.reverse().forEach(({ start, end, value }) => { const normalizedSpecifier = getNormalizedSpecifier(directory, filePath, value); if (normalizedSpecifier && normalizedSpecifier !== value) { output = `${output.slice(0, start)}${normalizedSpecifier}${output.slice(end)}`; replacements++; } }); if (output !== source) { // eslint-disable-next-line security/detect-non-literal-fs-filename fs.writeFileSync(filePath, output, "utf8"); } }); return { files: declarationFiles.length, replacements, }; }; /** * */ /** * Normalizes declaration imports after all Rollup configurations finish writing the package. * @returns Rollup plugin instance. */ export default function NormalizeDeclarationImportsPlugin({ builds, directory, }: INormalizeDeclarationImportsPluginOptions): Plugin { let completedBuilds = 0; return { name: "normalize-declaration-imports", closeBundle() { completedBuilds++; if (completedBuilds % builds === 0) { normalizeDeclarationImports(directory); } }, }; }