diff --git a/.idea/typescript-transform-paths.iml b/.idea/typescript-transform-paths.iml index 72ee7465..d7da1b60 100755 --- a/.idea/typescript-transform-paths.iml +++ b/.idea/typescript-transform-paths.iml @@ -10,5 +10,6 @@ + diff --git a/README.md b/README.md index 62b04a62..c07f7f1b 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,57 @@ import '#root/file2.ts' // resolves to '../file2' import '#root/file1.ts' // resolves to '../file1' ``` +## Custom Control + +### Exclusion patterns + +You can disable transformation for paths based on the resolved file path. The `exclude` option allows specifying glob +patterns to match against resolved file path. + +For an example context in which this would be useful, see [Issue #83](https://github.com/LeDDGroup/typescript-transform-paths/issues/83) + +Example: +```jsonc +{ + "compilerOptions": { + "paths": { + "sub-module1/*": [ "../../node_modules/sub-module1/*" ], + "sub-module2/*": [ "../../node_modules/sub-module2/*" ], + }, + "plugins": [ + { + "transform": "typescript-transform-paths", + "useRootDirs": true, + exclude: [ "**/node_modules/**" ] + } + ] + } +} +``` + +```ts +// This path will not be transformed +import * as sm1 from 'sub-module1/index' +``` + +### @transform-path tag + +Use the `@transform-path` tag to explicitly specify the output path for a single statement. + +```ts +// @transform-path https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js +import react from 'react' // Output path will be the url above +``` + +### @no-transform-path + +Use the `@no-transform-path` tag to explicitly disable transformation for a single statement. + +```ts +// @no-transform-path +import 'normally-transformed' // This will remain 'normally-transformed', even though it has a different value in paths config +``` + ## Articles - [Node Consumable Modules With Typescript Paths](https://medium.com/@ole.ersoy/node-consumable-modules-with-typescript-paths-ed88a5f332fa?postPublishedType=initial) by [oleersoy](https://github.com/oleersoy") diff --git a/package.json b/package.json index ded4d01c..7a48333c 100755 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/jest": "^24.0.15", + "@types/minimatch": "^3.0.3", "@types/node": "^12.0.2", "jest": "^24.8.0", "prettier": "^2.1.2", @@ -70,5 +71,8 @@ }, "peerDependencies": { "typescript": ">=3.6.5" + }, + "dependencies": { + "minimatch": "^3.0.4" } } diff --git a/src/transformer.ts b/src/transformer.ts index 90bc1d34..68c156ae 100755 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -6,6 +6,7 @@ import { cast, getImplicitExtensions } from "./utils"; import { TsTransformPathsConfig, TsTransformPathsContext, TypeScriptThree, VisitorContext } from "./types"; import { nodeVisitor } from "./visitor"; import { createHarmonyFactory } from "./utils/harmony-factory"; +import { Minimatch } from "minimatch"; /* ****************************************************************************************************************** * * Transformer @@ -34,6 +35,7 @@ export default function transformer( transformationContext, tsInstance, tsThreeInstance: cast(tsInstance), + excludeMatchers: config.exclude?.map((globPattern) => new Minimatch(globPattern, { matchBase: true })), }; return (sourceFile: ts.SourceFile) => { diff --git a/src/types.ts b/src/types.ts index 226b0fcb..35d2397a 100755 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import tsThree from "./declarations/typescript3"; import ts, { CompilerOptions } from "typescript"; import { PluginConfig } from "ts-patch"; import { HarmonyFactory } from "./utils/harmony-factory"; +import { IMinimatch } from "minimatch"; /* ****************************************************************************************************************** */ // region: TS Types @@ -20,7 +21,7 @@ export type ImportOrExportClause = ts.ImportDeclaration["importClause"] | ts.Exp export interface TsTransformPathsConfig extends PluginConfig { readonly useRootDirs?: boolean; - readonly overwriteNodeModules?: boolean; + readonly exclude?: string[]; } // endregion @@ -46,6 +47,7 @@ export interface TsTransformPathsContext { readonly elisionMap: Map>; readonly transformationContext: ts.TransformationContext; readonly rootDirs?: string[]; + readonly excludeMatchers: IMinimatch[] | undefined; } export interface VisitorContext extends TsTransformPathsContext { diff --git a/src/utils/resolve-path-update-node.ts b/src/utils/resolve-path-update-node.ts index cc3cc443..cba5354e 100755 --- a/src/utils/resolve-path-update-node.ts +++ b/src/utils/resolve-path-update-node.ts @@ -17,56 +17,114 @@ export function resolvePathAndUpdateNode( moduleName: string, updaterFn: (newPath: ts.StringLiteral) => ts.Node | tsThree.Node | undefined ): ts.Node | undefined { - const { sourceFile, compilerOptions, tsInstance, config, rootDirs, implicitExtensions, factory } = context; - - /* Have Compiler API attempt to resolve */ - const { resolvedModule, failedLookupLocations } = tsInstance.resolveModuleName( - moduleName, - sourceFile.fileName, - compilerOptions, - tsInstance.sys - ); - - if (!config.overwriteNodeModules && resolvedModule?.isExternalLibraryImport) return node; - - let outputPath: string; - if (!resolvedModule || config.overwriteNodeModules) { - const maybeURL = failedLookupLocations[0]; - if (!isURL(maybeURL)) return node; - outputPath = maybeURL; - } else { + const { sourceFile, compilerOptions, tsInstance, config, implicitExtensions, factory } = context; + const tags = getStatementTags(); + + // Skip if @no-transform-path specified + if (tags?.shouldSkip) return node; + + const resolutionResult = resolvePath(tags?.overridePath); + + // Skip if can't be resolved + if (!resolutionResult || !resolutionResult.outputPath) return node; + + const { outputPath, filePath } = resolutionResult; + + // Check if matches exclusion + if (filePath && context.excludeMatchers) + for (const matcher of context.excludeMatchers) if (matcher.match(filePath)) return node; + + return updaterFn(factory.createStringLiteral(outputPath)) as ts.Node | undefined; + + /* ********************************************************* * + * Helpers + * ********************************************************* */ + + function resolvePath(overridePath: string | undefined): { outputPath: string; filePath?: string } | undefined { + /* Handle overridden path -- ie. @transform-path ../my/path) */ + if (overridePath) { + return { + outputPath: filePathToOutputPath(overridePath, path.extname(overridePath)), + filePath: overridePath, + }; + } + + /* Have Compiler API attempt to resolve */ + const { resolvedModule, failedLookupLocations } = tsInstance.resolveModuleName( + moduleName, + sourceFile.fileName, + compilerOptions, + tsInstance.sys + ); + + // No transform for node-modules + if (resolvedModule?.isExternalLibraryImport) return void 0; + + /* Handle non-resolvable module */ + if (!resolvedModule) { + const maybeURL = failedLookupLocations[0]; + if (!isURL(maybeURL)) return void 0; + return { outputPath: maybeURL }; + } + + /* Handle resolved module */ const { extension, resolvedFileName } = resolvedModule; + return { + outputPath: filePathToOutputPath(resolvedFileName, extension), + filePath: resolvedFileName, + }; + } - const fileName = sourceFile.fileName; - let filePath = tsInstance.normalizePath(path.dirname(sourceFile.fileName)); - let modulePath = path.dirname(resolvedFileName); - - /* Handle rootDirs mapping */ - if (config.useRootDirs && rootDirs) { - let fileRootDir = ""; - let moduleRootDir = ""; - for (const rootDir of rootDirs) { - if (isBaseDir(rootDir, resolvedFileName) && rootDir.length > moduleRootDir.length) moduleRootDir = rootDir; - if (isBaseDir(rootDir, fileName) && rootDir.length > fileRootDir.length) fileRootDir = rootDir; - } + function filePathToOutputPath(filePath: string, extension: string | undefined) { + if (path.isAbsolute(filePath)) { + let sourceFileDir = tsInstance.normalizePath(path.dirname(sourceFile.fileName)); + let moduleDir = path.dirname(filePath); + + /* Handle rootDirs mapping */ + if (config.useRootDirs && context.rootDirs) { + let fileRootDir = ""; + let moduleRootDir = ""; + for (const rootDir of context.rootDirs) { + if (isBaseDir(rootDir, filePath) && rootDir.length > moduleRootDir.length) moduleRootDir = rootDir; + if (isBaseDir(rootDir, sourceFile.fileName) && rootDir.length > fileRootDir.length) fileRootDir = rootDir; + } - /* Remove base dirs to make relative to root */ - if (fileRootDir && moduleRootDir) { - filePath = path.relative(fileRootDir, filePath); - modulePath = path.relative(moduleRootDir, modulePath); + /* Remove base dirs to make relative to root */ + if (fileRootDir && moduleRootDir) { + sourceFileDir = path.relative(fileRootDir, sourceFileDir); + moduleDir = path.relative(moduleRootDir, moduleDir); + } } + + /* Make path relative */ + filePath = tsInstance.normalizePath(path.join(path.relative(sourceFileDir, moduleDir), path.basename(filePath))); } - /* Remove extension if implicit */ - outputPath = tsInstance.normalizePath( - path.join(path.relative(filePath, modulePath), path.basename(resolvedFileName)) - ); - if (extension && implicitExtensions.includes(extension)) outputPath = outputPath.slice(0, -extension.length); - if (!outputPath) return node; + // Remove extension if implicit + if (extension && implicitExtensions.includes(extension)) filePath = filePath.slice(0, -extension.length); - outputPath = outputPath[0] === "." ? outputPath : `./${outputPath}`; + return filePath[0] === "." || isURL(filePath) ? filePath : `./${filePath}`; } - const newStringLiteral = factory.createStringLiteral(outputPath); - return updaterFn(newStringLiteral) as ts.Node | undefined; + function getStatementTags() { + const targetNode = tsInstance.isStatement(node) + ? node + : tsInstance.findAncestor(node, tsInstance.isStatement) ?? node; + const jsDocTags = tsInstance.getJSDocTags(targetNode); + + const trivia = targetNode.getFullText(sourceFile).slice(0, targetNode.getLeadingTriviaWidth(sourceFile)); + const commentTags = new Map(); + const regex = /^\s*\/\/\/?\s*@(transform-path|no-transform-path)(?:[^\S\r\n](.+?))?$/gm; + + for (let match = regex.exec(trivia); match; match = regex.exec(trivia)) commentTags.set(match[1], match[2]); + + return { + overridePath: + commentTags.get("transform-path") ?? + jsDocTags?.find((t) => t.tagName.text.toLowerCase() === "transform-path")?.comment, + shouldSkip: + commentTags.has("no-transform-path") || + !!jsDocTags?.find((t) => t.tagName.text.toLowerCase() === "no-transform-path"), + }; + } } diff --git a/src/visitor.ts b/src/visitor.ts index 4e91603a..e65bd7bd 100755 --- a/src/visitor.ts +++ b/src/visitor.ts @@ -109,7 +109,7 @@ export function nodeVisitor(this: VisitorContext, node: ts.Node): ts.Node | unde return resolvePathAndUpdateNode(this, node, node.moduleSpecifier.text, (p) => { let importClause = node.importClause; - if (!this.isDeclarationFile && importClause) { + if (!this.isDeclarationFile && importClause?.namedBindings) { const updatedImportClause = elideImportOrExportClause(this, node); if (!updatedImportClause) return undefined; // No imports left, elide entire declaration importClause = updatedImportClause; @@ -127,7 +127,7 @@ export function nodeVisitor(this: VisitorContext, node: ts.Node): ts.Node | unde return resolvePathAndUpdateNode(this, node, node.moduleSpecifier.text, (p) => { let exportClause = node.exportClause; - if (!this.isDeclarationFile && exportClause) { + if (!this.isDeclarationFile && exportClause && tsInstance.isNamedExports(exportClause)) { const updatedExportClause = elideImportOrExportClause(this, node); if (!updatedExportClause) return undefined; // No export left, elide entire declaration exportClause = updatedExportClause; diff --git a/test/package.json b/test/package.json index 0f1862ec..a2c9577d 100755 --- a/test/package.json +++ b/test/package.json @@ -12,6 +12,6 @@ "ts-patch": "link:../node_modules/ts-patch" }, "workspaces": [ - "packages/*" + "projects/*" ] } diff --git a/test/projects/specific/src/excluded-file.ts b/test/projects/specific/src/excluded-file.ts new file mode 100755 index 00000000..061a3364 --- /dev/null +++ b/test/projects/specific/src/excluded-file.ts @@ -0,0 +1 @@ +export type DD = number; diff --git a/test/projects/specific/src/excluded/ex.ts b/test/projects/specific/src/excluded/ex.ts new file mode 100755 index 00000000..0b855606 --- /dev/null +++ b/test/projects/specific/src/excluded/ex.ts @@ -0,0 +1 @@ +export type BB = number; diff --git a/test/projects/specific/src/index.ts b/test/projects/specific/src/index.ts index 367a31c0..a8596397 100755 --- a/test/projects/specific/src/index.ts +++ b/test/projects/specific/src/index.ts @@ -14,3 +14,8 @@ import( */ "#root/dir/gen-file" ); + +export { BB } from "#exclusion/ex"; +export { DD } from "#root/excluded-file"; + +export const b = 3; diff --git a/test/projects/specific/src/tags.ts b/test/projects/specific/src/tags.ts new file mode 100755 index 00000000..f8f1e2c1 --- /dev/null +++ b/test/projects/specific/src/tags.ts @@ -0,0 +1,62 @@ +/* ****************************************************************************************************************** * + * JSDoc + * ****************************************************************************************************************** */ + +/** + * @no-transform-path + */ +import * as skipTransform1 from "#root/index"; + +/** + * @multi-tag1 + * @no-transform-path + * @multi-tag2 + */ +import * as skipTransform2 from "#root/index"; + +/** + * @multi-tag1 + * @transform-path ./dir/src-file + * @multi-tag2 + */ +import * as explicitTransform1 from "./index"; + +/** + * @multi-tag1 + * @transform-path http://www.go.com/react.js + * @multi-tag2 + */ +import * as explicitTransform2 from "./index"; + +/* ****************************************************************************************************************** * + * JS Tag + * ****************************************************************************************************************** */ + +// @no-transform-path +import * as skipTransform3 from "#root/index"; + +// @multi-tag1 +// @no-transform-path +// @multi-tag2 +import * as skipTransform4 from "#root/index"; + +// @multi-tag1 +// @transform-path ./dir/src-file +// @multi-tag2 +import * as explicitTransform3 from "./index"; + +// @multi-tag1 +// @transform-path http://www.go.com/react.js +// @multi-tag2 +import * as explicitTransform4 from "./index"; + +export { + skipTransform1, + skipTransform2, + skipTransform3, + skipTransform4, + explicitTransform1, + explicitTransform2, + explicitTransform3, + explicitTransform4, +}; diff --git a/test/projects/specific/tsconfig.json b/test/projects/specific/tsconfig.json index b4cc0d42..b369a26f 100755 --- a/test/projects/specific/tsconfig.json +++ b/test/projects/specific/tsconfig.json @@ -7,13 +7,15 @@ "rootDir": ".", "module": "ESNext", "esModuleInterop": true, + "moduleResolution": "node", "declaration": true, "rootDirs": [ "src", "generated" ], "baseUrl": ".", "paths": { - "#root/*": [ "./src/*", "./generated/*" ] + "#root/*": [ "./src/*", "./generated/*" ], + "#exclusion/*": [ "./src/excluded/*" ] } } } diff --git a/test/tests/transformer/specific.test.ts b/test/tests/transformer/specific.test.ts index f25f117e..48a79345 100755 --- a/test/tests/transformer/specific.test.ts +++ b/test/tests/transformer/specific.test.ts @@ -3,6 +3,7 @@ import {} from "ts-expose-internals"; import * as path from "path"; import { createTsProgram, EmittedFiles, getEmitResult } from "../../utils"; import { projectsPaths, ts, tsModules, tTypeScript } from "../config"; +import { TsTransformPathsConfig } from "../../../src/types"; /* ****************************************************************************************************************** * * Config @@ -22,7 +23,9 @@ describe(`Transformer -> Specific Cases`, () => { const genFile = ts.normalizePath(path.join(projectRoot, "generated/dir/gen-file.ts")); const srcFile = ts.normalizePath(path.join(projectRoot, "src/dir/src-file.ts")); const indexFile = ts.normalizePath(path.join(projectRoot, "src/index.ts")); + const tagFile = ts.normalizePath(path.join(projectRoot, "src/tags.ts")); const typeElisionIndex = ts.normalizePath(path.join(projectRoot, "src/type-elision/index.ts")); + const baseConfig: TsTransformPathsConfig = { exclude: ["**/excluded/**", "excluded-file.*"] }; describe.each(testTsModules)(`TypeScript %s`, (s, tsInstance) => { let rootDirsEmit: EmittedFiles; @@ -30,18 +33,83 @@ describe(`Transformer -> Specific Cases`, () => { const tsVersion = +tsInstance.versionMajorMinor.split(".").slice(0, 2).join(""); beforeAll(() => { - const program = createTsProgram({ tsInstance, tsConfigFile, pluginOptions: { useRootDirs: false } }); + const program = createTsProgram({ + tsInstance, + tsConfigFile, + pluginOptions: { + ...baseConfig, + useRootDirs: false, + }, + }); normalEmit = getEmitResult(program); - const rootDirsProgram = createTsProgram({ tsInstance, tsConfigFile, pluginOptions: { useRootDirs: true } }); + const rootDirsProgram = createTsProgram({ + tsInstance, + tsConfigFile, + pluginOptions: { + ...baseConfig, + useRootDirs: true, + }, + }); rootDirsEmit = getEmitResult(rootDirsProgram); }); - test(`(useRootDirs: true) Re-maps for rootDirs`, () => { - expect(rootDirsEmit[genFile].dts).toMatch(`import "./src-file"`); - expect(rootDirsEmit[srcFile].dts).toMatch(`import "./gen-file"`); - expect(rootDirsEmit[indexFile].dts).toMatch(`export { B } from "./dir/gen-file"`); - expect(rootDirsEmit[indexFile].dts).toMatch(`export { A } from "./dir/src-file"`); + describe(`Options`, () => { + test(`(useRootDirs: true) Re-maps for rootDirs`, () => { + expect(rootDirsEmit[genFile].dts).toMatch(`import "./src-file"`); + expect(rootDirsEmit[srcFile].dts).toMatch(`import "./gen-file"`); + expect(rootDirsEmit[indexFile].dts).toMatch(`export { B } from "./dir/gen-file"`); + expect(rootDirsEmit[indexFile].dts).toMatch(`export { A } from "./dir/src-file"`); + }); + + test(`(useRootDirs: false) Ignores rootDirs`, () => { + expect(normalEmit[genFile].dts).toMatch(`import "../../src/dir/src-file"`); + expect(normalEmit[srcFile].dts).toMatch(`import "../../generated/dir/gen-file"`); + expect(normalEmit[indexFile].dts).toMatch(`export { B } from "../generated/dir/gen-file"`); + expect(normalEmit[indexFile].dts).toMatch(`export { A } from "./dir/src-file"`); + }); + + test(`(exclude) Doesn't transform for exclusion patterns`, () => { + expect(rootDirsEmit[indexFile].dts).toMatch( + /export { BB } from "#exclusion\/ex";\s*export { DD } from "#root\/excluded-file";/ + ); + }); + }); + + describe(`Tags`, () => { + test(`(@no-transform-path) Doesn't transform path`, () => { + const regex = /^import \* as skipTransform\d from "#root\/index"/gm; + const expectedLength = tsInstance.versionMajorMinor === "3.6" ? 8 : 16; + const matches = [ + ...(normalEmit[tagFile].dts.match(regex) ?? []), + ...(rootDirsEmit[tagFile].dts.match(regex) ?? []), + ...(normalEmit[tagFile].js.match(regex) ?? []), + ...(rootDirsEmit[tagFile].js.match(regex) ?? []), + ]; + expect(matches).toHaveLength(expectedLength); + }); + + test(`(@transform-path) Transforms path with explicit value`, () => { + const regex1 = /^import \* as explicitTransform\d from "\.\/dir\/src-file"/gm; + const regex2 = /^import \* as explicitTransform\d from "http:\/\/www\.go\.com\/react\.js"/gm; + const expectedLength = tsInstance.versionMajorMinor === "3.6" ? 4 : 8; + + const matches1 = [ + ...(normalEmit[tagFile].dts.match(regex1) ?? []), + ...(rootDirsEmit[tagFile].dts.match(regex1) ?? []), + ...(normalEmit[tagFile].js.match(regex1) ?? []), + ...(rootDirsEmit[tagFile].js.match(regex1) ?? []), + ]; + expect(matches1).toHaveLength(expectedLength); + + const matches2 = [ + ...(normalEmit[tagFile].dts.match(regex2) ?? []), + ...(rootDirsEmit[tagFile].dts.match(regex2) ?? []), + ...(normalEmit[tagFile].js.match(regex2) ?? []), + ...(rootDirsEmit[tagFile].js.match(regex2) ?? []), + ]; + expect(matches2).toHaveLength(expectedLength); + }); }); test(`Does not resolve external modules`, () => { @@ -66,12 +134,5 @@ describe(`Transformer -> Specific Cases`, () => { /\/\/ comment 1\r?\n\s*\r?\n\/\*\r?\n\s*comment 2\r?\n\s*\*\/\r?\n\s*"\.\.\/generated\/dir\/gen-file"/ ); }); - - test(`(useRootDirs: false) Ignores rootDirs`, () => { - expect(normalEmit[genFile].dts).toMatch(`import "../../src/dir/src-file"`); - expect(normalEmit[srcFile].dts).toMatch(`import "../../generated/dir/gen-file"`); - expect(normalEmit[indexFile].dts).toMatch(`export { B } from "../generated/dir/gen-file"`); - expect(normalEmit[indexFile].dts).toMatch(`export { A } from "./dir/src-file"`); - }); }); }); diff --git a/test/yarn.lock b/test/yarn.lock index a3c2ae9a..6941ed1f 100755 --- a/test/yarn.lock +++ b/test/yarn.lock @@ -207,6 +207,10 @@ ts-expose-internals@^4.1.2: resolved "https://registry.yarnpkg.com/ts-expose-internals/-/ts-expose-internals-4.1.2.tgz#c89a64be5b7ae6634d2f0f25dffa798e2931d136" integrity sha512-J5xYsQocO5tjP2UMkGlOb5U+joore0AiL1drA5bMPganlQR/9gPmQVO55pQDeHVfy8OATqZaBT5JweyjI6f3eA== +"ts-patch-alias@link:../node_modules/ts-patch": + version "0.0.0" + uid "" + "ts-patch@link:../node_modules/ts-patch": version "0.0.0" uid "" diff --git a/yarn.lock b/yarn.lock index 6bc0fe12..769b9714 100644 --- a/yarn.lock +++ b/yarn.lock @@ -405,6 +405,11 @@ dependencies: jest-diff "^24.3.0" +"@types/minimatch@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== + "@types/minimist@^1.2.0": version "1.2.1" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256"