From 6c0c2f995ad436a227c166addac0eae846e797f7 Mon Sep 17 00:00:00 2001 From: SimeonC <1085899+SimeonC@users.noreply.github.com> Date: Thu, 12 Sep 2024 13:53:03 +0900 Subject: [PATCH] feat: add vite import massager plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should improve eslint performance for negligable vite performance as the straight string transforms are faster than the TS parsing needed for the eslint-plugin rule BREAKING CHANGE: eslint-plugin has ‘forbidden imports’ replaced with vite plugin --- package-lock.json | 72 ++++-- package.json | 2 +- packages/eslint-config/package.json | 2 +- packages/eslint-config/src/rules/general.ts | 1 - packages/eslint-plugin/README.md | 9 +- .../__tests__/forbiddenImports.test.ts | 135 ---------- .../docs/rules/forbidden-imports.md | 5 - packages/eslint-plugin/package.json | 2 +- .../eslint-plugin/src/forbiddenImports.ts | 152 ----------- packages/eslint-plugin/src/index.ts | 2 - .../vite-import-massager-plugin/README.md | 46 ++++ .../vite-import-massager-plugin/package.json | 38 +++ .../vite-import-massager-plugin/project.json | 44 ++++ .../src/index.spec.ts | 243 ++++++++++++++++++ .../vite-import-massager-plugin/src/index.ts | 155 +++++++++++ .../vite-import-massager-plugin/tsconfig.json | 24 ++ .../tsconfig.lib.json | 19 ++ .../tsconfig.spec.json | 26 ++ .../vite.config.ts | 26 ++ tsconfig.base.json | 5 +- tsconfig.eslint.json | 3 +- vitest.workspace.ts | 1 + 22 files changed, 688 insertions(+), 324 deletions(-) delete mode 100644 packages/eslint-plugin/__tests__/forbiddenImports.test.ts delete mode 100644 packages/eslint-plugin/docs/rules/forbidden-imports.md delete mode 100644 packages/eslint-plugin/src/forbiddenImports.ts create mode 100644 packages/vite-import-massager-plugin/README.md create mode 100644 packages/vite-import-massager-plugin/package.json create mode 100644 packages/vite-import-massager-plugin/project.json create mode 100644 packages/vite-import-massager-plugin/src/index.spec.ts create mode 100644 packages/vite-import-massager-plugin/src/index.ts create mode 100644 packages/vite-import-massager-plugin/tsconfig.json create mode 100644 packages/vite-import-massager-plugin/tsconfig.lib.json create mode 100644 packages/vite-import-massager-plugin/tsconfig.spec.json create mode 100644 packages/vite-import-massager-plugin/vite.config.ts create mode 100644 vitest.workspace.ts diff --git a/package-lock.json b/package-lock.json index cddb0080..b2f20050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "storybook": "^6.5.16", "type-fest": "4.4.0", "typescript": "5.4.5", - "vite": "5.3.1", + "vite": "5.3.5", "vite-plugin-eslint": "^1.8.1", "vite-tsconfig-paths": "4.3.2", "vitest": "1.6.0" @@ -2484,6 +2484,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -3625,9 +3626,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -9481,6 +9482,10 @@ "resolved": "packages/semantic-release-config", "link": true }, + "node_modules/@tablecheck/vite-import-massager-plugin": { + "resolved": "packages/vite-import-massager-plugin", + "link": true + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -25692,12 +25697,11 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", - "dev": true, + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { @@ -36038,13 +36042,13 @@ } }, "node_modules/vite": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz", - "integrity": "sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", + "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", "dev": true, "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.38", + "postcss": "^8.4.39", "rollup": "^4.13.0" }, "bin": { @@ -36560,9 +36564,9 @@ } }, "node_modules/vite/node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", "dev": true, "funding": [ { @@ -36580,7 +36584,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -38623,7 +38627,7 @@ }, "devDependencies": { "eslint": "^8", - "vite": "5.3.1", + "vite": "5.3.5", "vite-tsconfig-paths": "4.3.2" }, "engines": { @@ -38652,7 +38656,7 @@ "prettier": "3.0.3", "type-fest": "4.4.0", "typescript": "5.1.6", - "vite": "5.3.1", + "vite": "5.3.5", "vite-tsconfig-paths": "4.3.2", "vitest": "1.6.0" }, @@ -38980,6 +38984,36 @@ "engines": { "node": ">= 16.16.0" } + }, + "packages/vite-import-massager-plugin": { + "name": "@tablecheck/vite-import-massager-plugin", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.11" + }, + "devDependencies": { + "typescript": "5.1.6", + "vite": "5.3.5", + "vite-tsconfig-paths": "4.3.2", + "vitest": "1.6.0" + }, + "peerDependencies": { + "vite": "^5" + } + }, + "packages/vite-import-massager-plugin/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index 78a82cfc..0a8efc79 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "storybook": "^6.5.16", "type-fest": "4.4.0", "typescript": "5.4.5", - "vite": "5.3.1", + "vite": "5.3.5", "vite-plugin-eslint": "^1.8.1", "vite-tsconfig-paths": "4.3.2", "vitest": "1.6.0" diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index c6466f01..5638e07c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -84,7 +84,7 @@ }, "devDependencies": { "eslint": "^8", - "vite": "5.3.1", + "vite": "5.3.5", "vite-tsconfig-paths": "4.3.2" }, "engines": { diff --git a/packages/eslint-config/src/rules/general.ts b/packages/eslint-config/src/rules/general.ts index 7905aa66..13ffe1e3 100644 --- a/packages/eslint-config/src/rules/general.ts +++ b/packages/eslint-config/src/rules/general.ts @@ -101,6 +101,5 @@ export const generalRules: Linter.RulesRecord = { }, ], 'no-unused-vars': 'error', - '@tablecheck/forbidden-imports': 'error', '@nx/enforce-module-boundaries': 'error', }; diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 71b0d5be..e850086c 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -21,10 +21,9 @@ npm install --save-dev @tablecheck/eslint-plugin 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ 💭 Requires type information. -| Name                    | Description | 🔧 | 💭 | -| :--------------------------------------------------------------- | :------------------------------------------------------------------------------------------ | :-- | :-- | -| [consistent-react-import](docs/rules/consistent-react-import.md) | Ensure that react is always imported and used consistently | 🔧 | | -| [forbidden-imports](docs/rules/forbidden-imports.md) | Ensure that certain packages are using specific imports instead of using the default import | 🔧 | | -| [prefer-shortest-import](docs/rules/prefer-shortest-import.md) | Enforce the consistent use of preferred import paths | 🔧 | 💭 | +| Name | Description | 🔧 | 💭 | +| :--------------------------------------------------------------- | :--------------------------------------------------------- | :-- | :-- | +| [consistent-react-import](docs/rules/consistent-react-import.md) | Ensure that react is always imported and used consistently | 🔧 | | +| [prefer-shortest-import](docs/rules/prefer-shortest-import.md) | Enforce the consistent use of preferred import paths | 🔧 | 💭 | diff --git a/packages/eslint-plugin/__tests__/forbiddenImports.test.ts b/packages/eslint-plugin/__tests__/forbiddenImports.test.ts deleted file mode 100644 index d834b8c4..00000000 --- a/packages/eslint-plugin/__tests__/forbiddenImports.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { RuleTester } from '@typescript-eslint/rule-tester'; -import { type TSESLint } from '@typescript-eslint/utils'; - -import { - forbiddenImports as rule, - type messageId, -} from '../src/forbiddenImports'; - -const ruleTester = new RuleTester({ - parser: '@typescript-eslint/parser', - parserOptions: { - project: './fixtures/tsconfig.test.json', - tsconfigRootDir: __dirname, - sourceType: 'module', - }, -}); - -const filename = './fixtures/test_src/default.tsx'; - -ruleTester.run('forbiddenImports > valid other import formats', rule, { - valid: [ - { code: `import 'moment/locales/en';`, filename }, - { code: `import something from 'moment';`, filename }, - { code: `import { something } from 'moment';`, filename }, - ], - invalid: [ - { - code: `import { merge } from 'lodash';`, - output: `import merge from 'lodash/merge';`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: 'lodash', - }, - }, - ], - }, - ], -}); - -ruleTester.run('forbiddenImports > lodash', rule, { - valid: [{ code: `import merge from 'lodash/merge';`, filename }], - invalid: [ - { - code: `import { merge as _merge } from 'lodash';`, - output: `import _merge from 'lodash/merge';`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: 'lodash', - }, - }, - ], - }, - { - code: `import lodash from 'lodash';lodash.merge();lodash.slice();`, - output: `import merge from 'lodash/merge';import slice from 'lodash/slice';merge();slice();`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: 'lodash', - }, - }, - ], - }, - { - code: `import lodash from 'lodash';lodash.merge();const merge = 'test';`, - output: `import merge1 from 'lodash/merge';merge1();const merge = 'test';`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: 'lodash', - }, - }, - ], - }, - ], -}); - -const testFaPackages = [ - '@fortawesome/pro-regular-svg-icons', - '@fortawesome/pro-solid-svg-icons', - '@fortawesome/free-regular-svg-icons', - '@fortawesome/free-brands-svg-icons', -]; - -ruleTester.run('forbiddenImports > @fortawesome', rule, { - valid: testFaPackages.reduce( - (result, packageName) => - result.concat([ - { code: `import { faIcon } from '${packageName}/faIcon';`, filename }, - ]), - [] as { code: string; filename: string }[], - ), - invalid: testFaPackages.reduce( - (result, packageName) => - result.concat([ - { - code: `import { faIcon } from '${packageName}';`, - output: `import { faIcon } from '${packageName}/faIcon';`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: packageName, - }, - }, - ], - }, - { - code: `import { faIcon1, faIcon2, faIcon3 } from '${packageName}';`, - output: `import { faIcon1 } from '${packageName}/faIcon1';import { faIcon2 } from '${packageName}/faIcon2';import { faIcon3 } from '${packageName}/faIcon3';`, - filename, - errors: [ - { - messageId: 'incorrectImport', - data: { - importName: packageName, - }, - }, - ], - }, - ]), - [] as TSESLint.RunTests['invalid'], - ), -}); diff --git a/packages/eslint-plugin/docs/rules/forbidden-imports.md b/packages/eslint-plugin/docs/rules/forbidden-imports.md deleted file mode 100644 index dc7bc891..00000000 --- a/packages/eslint-plugin/docs/rules/forbidden-imports.md +++ /dev/null @@ -1,5 +0,0 @@ -# Ensure that certain packages are using specific imports instead of using the default import (`@tablecheck/forbidden-imports`) - -🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). - - diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index e365c9bf..88164a40 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -36,7 +36,7 @@ "prettier": "3.0.3", "type-fest": "4.4.0", "typescript": "5.1.6", - "vite": "5.3.1", + "vite": "5.3.5", "vite-tsconfig-paths": "4.3.2", "vitest": "1.6.0" }, diff --git a/packages/eslint-plugin/src/forbiddenImports.ts b/packages/eslint-plugin/src/forbiddenImports.ts deleted file mode 100644 index af07273b..00000000 --- a/packages/eslint-plugin/src/forbiddenImports.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { TSESTree } from '@typescript-eslint/types'; -import { type TSESLint } from '@typescript-eslint/utils'; -import { type RuleFix } from '@typescript-eslint/utils/ts-eslint'; - -type ImportDeclaration = TSESTree.ImportDeclaration; -type ImportSpecifier = TSESTree.ImportSpecifier; -type Identifier = TSESTree.Identifier; -type Node = TSESTree.Node; - -export const messageId = 'incorrectImport'; - -function assertForbiddenImport(node: Node): asserts node is ImportDeclaration { - if (node.type !== TSESTree.AST_NODE_TYPES.ImportDeclaration) - throw new Error('Invalid node type'); - const importName = node.source.value; - const isForbiddenImport = [ - /^lodash$/, - /^@fortawesome\/(pro|free)-[a-z]+-svg-icons$/, - ].find((matcher) => importName.match(matcher)); - if (!isForbiddenImport) throw new Error('Not a forbidden import'); -} - -function findNameInReferences( - name: string, - references: { identifier: { name: string } }[], -) { - return references.find((r) => r.identifier.name === name); -} - -function getSafeName( - name: string, - references: { identifier: { name: string } }[], -) { - let offsetCount = 1; - let safeName = name; - let matchedReference = findNameInReferences(name, references); - while (matchedReference) { - safeName = `${name}${offsetCount}`; - - matchedReference = findNameInReferences(safeName, references); - offsetCount += 1; - } - return safeName; -} - -function renameImport( - importName: string, - subImportName: string, - packageName: string, -) { - if (importName !== 'lodash') { - if (subImportName === packageName) - return `import { ${subImportName} } from '${importName}/${packageName}';`; - return `import { ${packageName} as ${subImportName} } from '${importName}/${packageName}';`; - } - return `import ${subImportName} from '${importName}/${packageName}';`; -} - -export const forbiddenImports: TSESLint.RuleModule = { - meta: { - schema: [], - type: 'suggestion', - docs: { - description: - 'Ensure that certain packages are using specific imports instead of using the default import', - recommended: 'recommended', - url: 'https://github.com/tablecheck/frontend/tree/main/packages/eslint-plugin/docs/rules/forbidden-imports.md', - }, - fixable: 'code', - messages: { - [messageId]: - 'The default import "{{ importName }}" should be using a specific import', - }, - }, - defaultOptions: [], - create: (context) => ({ - ImportDeclaration(node) { - if (node.specifiers.length === 0) return; - const importName = node.source.value || ''; - - const scope = context.getScope(); - for (const importSpecifier of node.specifiers) { - try { - assertForbiddenImport(importSpecifier.parent); - } catch (e) { - return; - } - } - context.report({ - node, - messageId, - data: { - importName, - }, - fix(fixer) { - const replacements: RuleFix[] = []; - let newImports = ''; - node.specifiers.forEach((importSpecifier) => { - const localName = importSpecifier.local.name; - let importedName = localName; - if ((importSpecifier as ImportSpecifier).imported?.name) { - importedName = (importSpecifier as ImportSpecifier).imported.name; - } - if ( - importSpecifier.type === - TSESTree.AST_NODE_TYPES.ImportDefaultSpecifier - ) { - const replacementImports: [string, string][] = []; - - for (const ref of scope.references) { - if (ref.identifier.name === localName) { - const { parent } = ref.identifier; - switch (parent?.type) { - case TSESTree.AST_NODE_TYPES.MemberExpression: { - const memberName = (parent.property as Identifier).name; - const existingReplacement = replacementImports.find( - ([, replacementImportName]) => - replacementImportName === memberName, - ); - - const newImportName = existingReplacement - ? existingReplacement[0] - : getSafeName(memberName, scope.references); - replacements.push( - fixer.replaceTextRange(parent.range, newImportName), - ); - if (!existingReplacement) - replacementImports.push([newImportName, memberName]); - break; - } - default: - } - } - } - replacementImports.forEach(([subImportName, packageName]) => { - newImports += renameImport( - importName, - subImportName, - packageName, - ); - }); - } else { - newImports += renameImport(importName, localName, importedName); - } - }); - replacements.push(fixer.replaceTextRange(node.range, newImports)); - return replacements; - }, - }); - }, - }), -}; diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index d4886b1c..ee6b92f1 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -1,12 +1,10 @@ // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable import/no-import-module-exports */ import { consistentReactImport } from './consistentReactImport'; -import { forbiddenImports } from './forbiddenImports'; import { shortestImport } from './shortestImport'; module.exports = { rules: { - 'forbidden-imports': forbiddenImports, 'consistent-react-import': consistentReactImport, 'prefer-shortest-import': shortestImport, }, diff --git a/packages/vite-import-massager-plugin/README.md b/packages/vite-import-massager-plugin/README.md new file mode 100644 index 00000000..0f27eae8 --- /dev/null +++ b/packages/vite-import-massager-plugin/README.md @@ -0,0 +1,46 @@ +# vite-import-massager-plugin + +This package is to allow easy use of libraries like lodash or carbon icons but still get the performance needed for efficient code splitting and bundling. + +## Installation + +To install the plugins, run the following command: + +```sh +npm install --save-dev @tablecheck/vite-import-massager-plugin +``` + +## Usage + +In the `vite.config.ts` vite config file, import the plugin and initialise it as follows; + +```ts +import { defineConfig } from 'vite'; +import ImportMassagerPlugin from '@tablecheck/vite-import-massager-plugin'; + +export default defineConfig({ + plugins: [ + new ImportMassagerPlugin([ + 'lodash', + '@fortawesome/pro-regular-svg-icons', + '@fortawesome/pro-solid-svg-icons', + '@fortawesome/free-regular-svg-icons', + '@fortawesome/free-brands-svg-icons', + { + transformPackages: ['@tablecheck/tablekit'], + packageName: '@carbon/icons-react', + importTransform: (importName) => { + if (importName.startsWith('WatsonHealth')) + return `/es/watson-health/${importName.replace( + 'WatsonHealth', + '', + )}`; + if (importName.startsWith('Q') && importName.match(/^Q[A-Z]/g)) + return `/es/Q/${importName.slice(1)}`; + return `/es/${importName}`; + }, + }, + ]), + ], +}); +``` diff --git a/packages/vite-import-massager-plugin/package.json b/packages/vite-import-massager-plugin/package.json new file mode 100644 index 00000000..97b6a88b --- /dev/null +++ b/packages/vite-import-massager-plugin/package.json @@ -0,0 +1,38 @@ +{ + "name": "@tablecheck/vite-import-massager-plugin", + "description": "vite plugin for ensuring performant imports", + "license": "MIT", + "author": "TableCheck Inc.", + "repository": { + "type": "git", + "url": "git@github.com:tablecheck/frontend.git" + }, + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "dist/index.js", + "files": ["dist"], + "scripts": { + "build": "tsc -p ./tsconfig.lib.json" + }, + "dependencies": { + "magic-string": "^0.30.11" + }, + "peerDependencies": { + "vite": "^5" + }, + "devDependencies": { + "typescript": "5.1.6", + "vite": "5.3.5", + "vite-tsconfig-paths": "4.3.2", + "vitest": "1.6.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/vite-import-massager-plugin/project.json b/packages/vite-import-massager-plugin/project.json new file mode 100644 index 00000000..c936c9e7 --- /dev/null +++ b/packages/vite-import-massager-plugin/project.json @@ -0,0 +1,44 @@ +{ + "name": "vite-import-massager-plugin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/vite-import-massager-plugin/src", + "projectType": "library", + "tags": [], + "targets": { + "quality": { + "executor": "@tablecheck/nx:quality", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/vite-import-massager-plugin/**/*.ts"] + }, + "configurations": { + "format": { + "fix": true + } + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": [ + "{workspaceRoot}/coverage/packages/vite-import-massager-plugin" + ], + "options": { + "coverage": true, + "reporters": ["junit"], + "passWithNoTests": true, + "reportsDirectory": "../../coverage/packages/vite-import-massager-plugin" + } + }, + "test:watch": { + "executor": "@nx/vite:test", + "outputs": [ + "{workspaceRoot}/coverage/packages/vite-import-massager-plugin" + ], + "options": { + "passWithNoTests": true, + "watch": true, + "reportsDirectory": "../../coverage/packages/vite-import-massager-plugin" + } + } + } +} diff --git a/packages/vite-import-massager-plugin/src/index.spec.ts b/packages/vite-import-massager-plugin/src/index.spec.ts new file mode 100644 index 00000000..53c8692c --- /dev/null +++ b/packages/vite-import-massager-plugin/src/index.spec.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import ImportMassagingPlugin from './index.js'; + +let plugin: ImportMassagingPlugin; + +describe('@carbon/icons-react', () => { + beforeEach(() => { + plugin = new ImportMassagingPlugin([ + { + packageName: '@carbon/icons-react', + importTransform: (importName) => { + if (importName.startsWith('WatsonHealth')) + return `/es/watson-health/${importName.replace( + 'WatsonHealth', + '', + )}`; + if (importName.startsWith('Q') && importName.match(/^Q[A-Z]/g)) + return `/es/Q/${importName.slice(1)}`; + return `/es/${importName}`; + }, + }, + ]); + }); + + it('should not rewrite non matching imports', () => { + const { code } = plugin.transform( + `import * as React from "react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot(`"import * as React from "react";"`); + }); + + it('should rewrite single carbon icon', () => { + const { code } = plugin.transform( + `import { Text } from "@carbon/icons-react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import Text from "@carbon/icons-react/es/Text";"`, + ); + }); + + it('should rewrite watson health', () => { + const { code } = plugin.transform( + `import { WatsonHealthAiResults } from "@carbon/icons-react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import WatsonHealthAiResults from "@carbon/icons-react/es/watson-health/AiResults";"`, + ); + }); + + it('should rewrite Q', () => { + const { code } = plugin.transform( + `import { QHinton } from "@carbon/icons-react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import QHinton from "@carbon/icons-react/es/Q/Hinton";"`, + ); + }); + + it('should rewrite multiple carbon icon', () => { + const { code } = plugin.transform( + `import { Text, Close } from "@carbon/icons-react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import Text from "@carbon/icons-react/es/Text";import Close from "@carbon/icons-react/es/Close";"`, + ); + }); + + it('should rewrite alias', () => { + const { code } = plugin.transform( + `import { Text as TextAlias } from "@carbon/icons-react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import TextAlias from "@carbon/icons-react/es/Text";"`, + ); + }); + + it('should handle real-world example', () => { + const { code } = plugin.transform( + `import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { + TextAlignCenter, + TextAlignLeft as Left, + TextAlignRight as Right, + Close, +} from "@carbon/icons-react"; +import * as Dropdown from "@radix-ui/react-dropdown-menu"; +import { getCarbonIconSize } from "@tablecheck/tablekit-react"; +import { type Editor } from "@tiptap/react";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import * as React from "react"; +import { useTranslation } from "react-i18next"; +import TextAlignCenter from "@carbon/icons-react/es/TextAlignCenter";import Left from "@carbon/icons-react/es/TextAlignLeft";import Right from "@carbon/icons-react/es/TextAlignRight";import Close from "@carbon/icons-react/es/Close"; +import * as Dropdown from "@radix-ui/react-dropdown-menu"; +import { getCarbonIconSize } from "@tablecheck/tablekit-react"; +import { type Editor } from "@tiptap/react";"`, + ); + }); +}); + +describe('lodash', () => { + beforeEach(() => { + plugin = new ImportMassagingPlugin([ + { + packageName: 'lodash', + importTransform: (importName) => `/${importName}`, + }, + ]); + }); + + it('should not rewrite correct imports', () => { + const { code } = plugin.transform( + `import merge from "lodash/merge";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot(`"import merge from "lodash/merge";"`); + }); + + it('should rewrite aliased imports', () => { + const { code } = plugin.transform( + `import { merge as _merge } from "lodash";`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot(`"import _merge from "lodash/merge";"`); + }); + + it('should rewrite default imports', () => { + const { code } = plugin.transform( + `import lodash from "lodash";lodash.merge();lodash.slice();`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import __lodash_merge from "lodash/merge";import __lodash_slice from "lodash/slice";__lodash_merge();__lodash_slice();"`, + ); + }); + + it('should rewrite default _ import', () => { + const { code } = plugin.transform( + `import _ from "lodash";_.merge();_.slice();`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import __lodash_merge from "lodash/merge";import __lodash_slice from "lodash/slice";__lodash_merge();__lodash_slice();"`, + ); + }); + + it('should rewrite glob imports', () => { + const { code } = plugin.transform( + `import * as lodash from "lodash";lodash.merge();lodash.slice();`, + 'src/index.ts', + ); + expect(code).toMatchInlineSnapshot( + `"import __lodash_merge from "lodash/merge";import __lodash_slice from "lodash/slice";__lodash_merge();__lodash_slice();"`, + ); + }); +}); + +describe('other tests', () => { + it('should accept string config', () => { + plugin = new ImportMassagingPlugin(['@carbon/icons-react']); + const { code } = plugin.transform( + `import { Text } from "@carbon/icons-react";`, + 'src/index.js', + ); + expect(code).toMatchInlineSnapshot( + `"import Text from "@carbon/icons-react/Text";"`, + ); + }); + + it('should not transform node_modules', () => { + plugin = new ImportMassagingPlugin([ + { + packageName: '@carbon/icons-react', + importTransform: (importName) => `/es/${importName}`, + }, + ]); + const { code } = plugin.transform( + `import { Text } from "@carbon/icons-react";`, + 'package/node_modules/pack-name/dist/index.js', + ); + expect(code).toMatchInlineSnapshot( + `"import { Text } from "@carbon/icons-react";"`, + ); + }); + + it('should transform node_modules', () => { + plugin = new ImportMassagingPlugin([ + { + packageName: '@carbon/icons-react', + transformPackages: ['pack-name'], + importTransform: (importName) => `/es/${importName}`, + }, + ]); + const { code } = plugin.transform( + `import { Text } from "@carbon/icons-react";`, + 'package/node_modules/pack-name/index.js', + ); + expect(code).toMatchInlineSnapshot( + `"import Text from "@carbon/icons-react/es/Text";"`, + ); + }); + + it('should run both transforms', () => { + plugin = new ImportMassagingPlugin([ + { + packageName: 'lodash', + }, + { + packageName: '@carbon/icons-react', + importTransform: (importName) => { + if (importName.startsWith('WatsonHealth')) + return `/es/watson-health/${importName.replace( + 'WatsonHealth', + '', + )}`; + if (importName.startsWith('Q') && importName.match(/^Q[A-Z]/g)) + return `/es/Q/${importName.slice(1)}`; + return `/es/${importName}`; + }, + }, + ]); + const { code } = plugin.transform( + `import { Text } from "@carbon/icons-react"; +import _ from "lodash"; +_.merge();`, + 'src/index.tsx', + ); + expect(code).toMatchInlineSnapshot( + `"import Text from "@carbon/icons-react/es/Text"; +import __lodash_merge from "lodash/merge"; +__lodash_merge();"`, + ); + }); +}); diff --git a/packages/vite-import-massager-plugin/src/index.ts b/packages/vite-import-massager-plugin/src/index.ts new file mode 100644 index 00000000..b6939961 --- /dev/null +++ b/packages/vite-import-massager-plugin/src/index.ts @@ -0,0 +1,155 @@ +import MagicString from 'magic-string'; +import { type Plugin } from 'vite'; + +interface TransformConfig { + /** + * List of packages in node_modules that should also have their source code transformed + */ + transformPackages?: string[]; + packageName: string; + /** + * This function transforms how we take the used variable and resolve it to the postfix import path. + * By default this will append the variable name to the end of the import path. + * For example; + * `import { Text } from 'icons'` would pass `Text` as the `importName` argument. + * `import _ from 'lodash'` and subsequent `_.merge()` would pass `merge` as the `importName` argument. + * Note that the new import path expects to point to a file with a default export. + */ + importTransform?: (importName: string) => string; +} + +// eslint-disable-next-line import/no-default-export +export default class ImportMassagingPlugin implements Plugin { + name = 'import-massaging'; + + configs: TransformConfig[]; + + /** + * @param configs list of package names or transform configs to transform imports + */ + constructor(configs: (string | TransformConfig)[]) { + this.configs = configs.map((c) => + typeof c === 'string' ? { packageName: c } : c, + ); + this.transform = this.transform.bind(this); + } + + transform(code: string, id: string) { + if (!this.shouldTransform(code, id)) { + return { code, map: null }; + } + const source = new MagicString(code); + for (const config of this.configs) { + const escapedPackageName = config.packageName.replace(/\//gi, '\\/'); + source.replaceAll( + new RegExp( + `import {([^}]+)} from (['"])${escapedPackageName}['"];?`, + 'gi', + ), + (_, importsString: string, quote: string) => { + const imports = importsString.split(','); + let result = ''; + for (const importString of imports) { + if (!importString.trim()) continue; + const [importName, alias] = importString + .split(' as ') + .map((s) => s.trim()); + result += this.buildImport({ + config, + importName, + quote, + varName: alias || importName, + }); + } + return result; + }, + ); + const usagesReplacements: { + start: number; + end: number; + content: string; + }[] = []; + source.replaceAll( + new RegExp( + `import (?:\\* as )?([a-zA-Z0-9_]+) from (['"])${escapedPackageName}['"];?`, + 'gi', + ), + (_, groupImport: string, quote: string) => { + const usagesRegexp = new RegExp( + `\\b${groupImport}\\.([a-zA-Z0-9_]+)`, + 'gi', + ); + const imports: string[] = []; + let usage = usagesRegexp.exec(code); + while (usage) { + const [full, usageName] = usage; + const varName = `__${config.packageName.replace( + /[^a-z]+/gi, + '_', + )}_${usageName}`; + const importCode = this.buildImport({ + config, + importName: usageName, + varName, + quote, + }); + if (!imports.includes(importCode)) { + imports.push(importCode); + } + usagesReplacements.push({ + start: usage.index, + end: usage.index + full.length, + content: varName, + }); + usage = usagesRegexp.exec(code); + } + return imports.join(''); + }, + ); + usagesReplacements.forEach((replacement) => { + source.overwrite( + replacement.start, + replacement.end, + replacement.content, + ); + }); + } + + return { + code: source.toString(), + map: source.generateMap({ hires: true }), + }; + } + + shouldTransform(code: string, id: string) { + const isNodeModules = id.includes('/node_modules/'); + if (!isNodeModules) { + return /\.[cm]?[tj]sx?$/.test(id) && this.includesImport(code); + } + const isTransformPackage = this.configs.some( + (c) => c.transformPackages?.some((pkg) => id.includes(pkg)), + ); + return isTransformPackage && this.includesImport(code); + } + + includesImport(code: string) { + return this.configs.some((c) => code.includes(c.packageName)); + } + + buildImport({ + config, + importName, + varName, + quote, + }: { + config: TransformConfig; + importName: string; + quote: string; + varName: string; + }) { + const transform = config.importTransform ?? ((i) => `/${i}`); + return `import ${varName} from ${quote}${config.packageName}${transform( + importName, + )}${quote};`; + } +} diff --git a/packages/vite-import-massager-plugin/tsconfig.json b/packages/vite-import-massager-plugin/tsconfig.json new file mode 100644 index 00000000..7a167277 --- /dev/null +++ b/packages/vite-import-massager-plugin/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es6", + "lib": ["ESNext"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/vite-import-massager-plugin/tsconfig.lib.json b/packages/vite-import-massager-plugin/tsconfig.lib.json new file mode 100644 index 00000000..05b697da --- /dev/null +++ b/packages/vite-import-massager-plugin/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/*.ts", + "src/*.tsx", + "src/**/*.json", + "src/*.json" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "paths": {}, + "types": ["vite/client", "node"] + }, + "exclude": ["node_modules", "*.spec.ts"], + "files": [] +} diff --git a/packages/vite-import-massager-plugin/tsconfig.spec.json b/packages/vite-import-massager-plugin/tsconfig.spec.json new file mode 100644 index 00000000..3c002c21 --- /dev/null +++ b/packages/vite-import-massager-plugin/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/packages/vite-import-massager-plugin/vite.config.ts b/packages/vite-import-massager-plugin/vite.config.ts new file mode 100644 index 00000000..8eb4325d --- /dev/null +++ b/packages/vite-import-massager-plugin/vite.config.ts @@ -0,0 +1,26 @@ +/// +import { defineConfig } from 'vite'; +import viteTsConfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/vite-import-massager-plugin', + + plugins: [ + viteTsConfigPaths({ + root: '../../', + }), + ], + + test: { + globals: true, + cache: { + dir: '../../node_modules/.vitest/vite-import-massager-plugin', + }, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + + reporters: ['default'], + outputFile: + '../../coverage/packages/vite-import-massager-plugin/report.junit.xml', + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 5c0535e5..a39b148d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,7 +17,10 @@ "paths": { "@tablecheck/eslint-plugin": ["packages/eslint-plugin/src/index.ts"], "@tablecheck/frontend-audit": ["packages/audit/src/index.ts"], - "@tablecheck/nx": ["packages/nx/src/index.ts"] + "@tablecheck/nx": ["packages/nx/src/index.ts"], + "@tablecheck/vite-import-massager-plugin": [ + "packages/vite-import-massager-plugin/src/index.ts" + ] } }, "exclude": ["node_modules", "tmp"] diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 5454fdce..8790a54d 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "resolveJsonModule": true, - "module": "NodeNext" + "module": "NodeNext", + "lib": ["ESNext", "DOM"] }, "exclude": ["node_modules", "tmp"] } diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 00000000..3c983a24 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1 @@ +export default ['**/*/vite.config.ts', '**/*/vitest.config.ts'];