From 122ea9ee598e5fe0cdb0e9cf7b041ce46da99e0b Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:32:09 +0900 Subject: [PATCH 1/4] feat(transform-imports): add transform-imports plugin --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 + .github/ISSUE_TEMPLATE/feature_request.yml | 2 + .github/labeler.yml | 2 + .github/pr-labeler.yml | 4 + README.md | 3 + packages/transform-imports/CHANGELOG.md | 0 packages/transform-imports/README.md | 203 +++++++++++++ packages/transform-imports/package.json | 61 ++++ packages/transform-imports/src/index.ts | 271 ++++++++++++++++++ packages/transform-imports/src/template.ts | 79 +++++ packages/transform-imports/src/types.ts | 20 ++ .../tests/fixtures/antd/config.json | 6 + .../tests/fixtures/antd/input.js | 1 + .../tests/fixtures/antd/output.js | 4 + .../tests/fixtures/camel-case/config.json | 6 + .../tests/fixtures/camel-case/input.js | 4 + .../tests/fixtures/camel-case/output.js | 6 + .../tests/fixtures/default/config.json | 6 + .../tests/fixtures/default/input.js | 2 + .../tests/fixtures/default/output.js | 2 + .../fixtures/dynamic-imports/config.json | 5 + .../tests/fixtures/dynamic-imports/input.js | 11 + .../tests/fixtures/dynamic-imports/output.js | 9 + .../handle-default-import/config.json | 6 + .../fixtures/handle-default-import/input.js | 2 + .../fixtures/handle-default-import/output.js | 4 + .../fixtures/handle-default-mixed/config.json | 6 + .../fixtures/handle-default-mixed/input.js | 2 + .../fixtures/handle-default-mixed/output.js | 5 + .../handle-namespace-import/config.json | 6 + .../fixtures/handle-namespace-import/input.js | 2 + .../handle-namespace-import/output.js | 4 + .../tests/fixtures/kebab-case/config.json | 6 + .../tests/fixtures/kebab-case/input.js | 4 + .../tests/fixtures/kebab-case/output.js | 6 + .../tests/fixtures/mapping-export/config.json | 12 + .../tests/fixtures/mapping-export/input.js | 4 + .../tests/fixtures/mapping-export/output.js | 6 + .../tests/fixtures/mapping/config.json | 12 + .../tests/fixtures/mapping/input.js | 6 + .../tests/fixtures/mapping/output.js | 8 + .../tests/fixtures/member-import/config.json | 5 + .../tests/fixtures/member-import/input.js | 3 + .../tests/fixtures/member-import/output.js | 5 + .../tests/fixtures/mixed-default/config.json | 6 + .../tests/fixtures/mixed-default/input.js | 3 + .../tests/fixtures/mixed-default/output.js | 6 + .../fixtures/namespace-export/config.json | 7 + .../tests/fixtures/namespace-export/input.js | 1 + .../tests/fixtures/namespace-export/output.js | 2 + .../tests/fixtures/namespace/config.json | 6 + .../tests/fixtures/namespace/input.js | 2 + .../tests/fixtures/namespace/output.js | 2 + .../tests/fixtures/pascal-case/config.json | 6 + .../tests/fixtures/pascal-case/input.js | 4 + .../tests/fixtures/pascal-case/output.js | 5 + .../tests/fixtures/regex-export/config.json | 5 + .../tests/fixtures/regex-export/input.js | 3 + .../tests/fixtures/regex-export/output.js | 5 + .../tests/fixtures/regex/config.json | 5 + .../tests/fixtures/regex/input.js | 5 + .../tests/fixtures/regex/output.js | 7 + .../fixtures/side-effect-imports/config.json | 2 + .../fixtures/side-effect-imports/input.js | 2 + .../fixtures/side-effect-imports/output.js | 2 + .../tests/fixtures/simple-export/config.json | 13 + .../tests/fixtures/simple-export/input.js | 3 + .../tests/fixtures/simple-export/output.js | 7 + .../tests/fixtures/simple/config.json | 13 + .../tests/fixtures/simple/input.js | 5 + .../tests/fixtures/simple/output.js | 9 + .../skip-default-conversion/config.json | 6 + .../fixtures/skip-default-conversion/input.js | 4 + .../skip-default-conversion/output.js | 5 + .../tests/fixtures/snake-case/config.json | 6 + .../tests/fixtures/snake-case/input.js | 4 + .../tests/fixtures/snake-case/output.js | 5 + .../transform-imports/tests/transform.test.ts | 80 ++++++ packages/transform-imports/tsdown.config.ts | 9 + packages/transform-imports/vitest.config.ts | 7 + pnpm-lock.yaml | 16 ++ scripts/release.ts | 1 + 82 files changed, 1102 insertions(+) create mode 100644 packages/transform-imports/CHANGELOG.md create mode 100644 packages/transform-imports/README.md create mode 100644 packages/transform-imports/package.json create mode 100644 packages/transform-imports/src/index.ts create mode 100644 packages/transform-imports/src/template.ts create mode 100644 packages/transform-imports/src/types.ts create mode 100644 packages/transform-imports/tests/fixtures/antd/config.json create mode 100644 packages/transform-imports/tests/fixtures/antd/input.js create mode 100644 packages/transform-imports/tests/fixtures/antd/output.js create mode 100644 packages/transform-imports/tests/fixtures/camel-case/config.json create mode 100644 packages/transform-imports/tests/fixtures/camel-case/input.js create mode 100644 packages/transform-imports/tests/fixtures/camel-case/output.js create mode 100644 packages/transform-imports/tests/fixtures/default/config.json create mode 100644 packages/transform-imports/tests/fixtures/default/input.js create mode 100644 packages/transform-imports/tests/fixtures/default/output.js create mode 100644 packages/transform-imports/tests/fixtures/dynamic-imports/config.json create mode 100644 packages/transform-imports/tests/fixtures/dynamic-imports/input.js create mode 100644 packages/transform-imports/tests/fixtures/dynamic-imports/output.js create mode 100644 packages/transform-imports/tests/fixtures/handle-default-import/config.json create mode 100644 packages/transform-imports/tests/fixtures/handle-default-import/input.js create mode 100644 packages/transform-imports/tests/fixtures/handle-default-import/output.js create mode 100644 packages/transform-imports/tests/fixtures/handle-default-mixed/config.json create mode 100644 packages/transform-imports/tests/fixtures/handle-default-mixed/input.js create mode 100644 packages/transform-imports/tests/fixtures/handle-default-mixed/output.js create mode 100644 packages/transform-imports/tests/fixtures/handle-namespace-import/config.json create mode 100644 packages/transform-imports/tests/fixtures/handle-namespace-import/input.js create mode 100644 packages/transform-imports/tests/fixtures/handle-namespace-import/output.js create mode 100644 packages/transform-imports/tests/fixtures/kebab-case/config.json create mode 100644 packages/transform-imports/tests/fixtures/kebab-case/input.js create mode 100644 packages/transform-imports/tests/fixtures/kebab-case/output.js create mode 100644 packages/transform-imports/tests/fixtures/mapping-export/config.json create mode 100644 packages/transform-imports/tests/fixtures/mapping-export/input.js create mode 100644 packages/transform-imports/tests/fixtures/mapping-export/output.js create mode 100644 packages/transform-imports/tests/fixtures/mapping/config.json create mode 100644 packages/transform-imports/tests/fixtures/mapping/input.js create mode 100644 packages/transform-imports/tests/fixtures/mapping/output.js create mode 100644 packages/transform-imports/tests/fixtures/member-import/config.json create mode 100644 packages/transform-imports/tests/fixtures/member-import/input.js create mode 100644 packages/transform-imports/tests/fixtures/member-import/output.js create mode 100644 packages/transform-imports/tests/fixtures/mixed-default/config.json create mode 100644 packages/transform-imports/tests/fixtures/mixed-default/input.js create mode 100644 packages/transform-imports/tests/fixtures/mixed-default/output.js create mode 100644 packages/transform-imports/tests/fixtures/namespace-export/config.json create mode 100644 packages/transform-imports/tests/fixtures/namespace-export/input.js create mode 100644 packages/transform-imports/tests/fixtures/namespace-export/output.js create mode 100644 packages/transform-imports/tests/fixtures/namespace/config.json create mode 100644 packages/transform-imports/tests/fixtures/namespace/input.js create mode 100644 packages/transform-imports/tests/fixtures/namespace/output.js create mode 100644 packages/transform-imports/tests/fixtures/pascal-case/config.json create mode 100644 packages/transform-imports/tests/fixtures/pascal-case/input.js create mode 100644 packages/transform-imports/tests/fixtures/pascal-case/output.js create mode 100644 packages/transform-imports/tests/fixtures/regex-export/config.json create mode 100644 packages/transform-imports/tests/fixtures/regex-export/input.js create mode 100644 packages/transform-imports/tests/fixtures/regex-export/output.js create mode 100644 packages/transform-imports/tests/fixtures/regex/config.json create mode 100644 packages/transform-imports/tests/fixtures/regex/input.js create mode 100644 packages/transform-imports/tests/fixtures/regex/output.js create mode 100644 packages/transform-imports/tests/fixtures/side-effect-imports/config.json create mode 100644 packages/transform-imports/tests/fixtures/side-effect-imports/input.js create mode 100644 packages/transform-imports/tests/fixtures/side-effect-imports/output.js create mode 100644 packages/transform-imports/tests/fixtures/simple-export/config.json create mode 100644 packages/transform-imports/tests/fixtures/simple-export/input.js create mode 100644 packages/transform-imports/tests/fixtures/simple-export/output.js create mode 100644 packages/transform-imports/tests/fixtures/simple/config.json create mode 100644 packages/transform-imports/tests/fixtures/simple/input.js create mode 100644 packages/transform-imports/tests/fixtures/simple/output.js create mode 100644 packages/transform-imports/tests/fixtures/skip-default-conversion/config.json create mode 100644 packages/transform-imports/tests/fixtures/skip-default-conversion/input.js create mode 100644 packages/transform-imports/tests/fixtures/skip-default-conversion/output.js create mode 100644 packages/transform-imports/tests/fixtures/snake-case/config.json create mode 100644 packages/transform-imports/tests/fixtures/snake-case/input.js create mode 100644 packages/transform-imports/tests/fixtures/snake-case/output.js create mode 100644 packages/transform-imports/tests/transform.test.ts create mode 100644 packages/transform-imports/tsdown.config.ts create mode 100644 packages/transform-imports/vitest.config.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index eb21a63..c2f8518 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -21,6 +21,8 @@ body: [plugin-jsx-remove-attributes](https://github.com/rolldown/plugins/tree/main/packages/jsx-remove-attributes) - label: |- [plugin-styled-jsx](https://github.com/rolldown/plugins/tree/main/packages/styled-jsx) + - label: |- + [plugin-transform-imports](https://github.com/rolldown/plugins/tree/main/packages/transform-imports) - type: textarea id: bug-description attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index bc37b74..ac3921b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -21,6 +21,8 @@ body: [plugin-jsx-remove-attributes](https://github.com/rolldown/plugins/tree/main/packages/jsx-remove-attributes) - label: |- [plugin-styled-jsx](https://github.com/rolldown/plugins/tree/main/packages/styled-jsx) + - label: |- + [plugin-transform-imports](https://github.com/rolldown/plugins/tree/main/packages/transform-imports) - type: textarea id: feature-description attributes: diff --git a/.github/labeler.yml b/.github/labeler.yml index 6de7aa1..2785c4a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,3 +6,5 @@ - '/- \[x\] \[plugin-jsx-remove-attributes\]/i' 'plugin: styled-jsx': - '/- \[x\] \[plugin-styled-jsx\]/i' +'plugin: transform-imports': + - '/- \[x\] \[plugin-transform-imports\]/i' diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index f62a370..f6ef885 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -14,6 +14,10 @@ - changed-files: - any-glob-to-any-file: 'packages/styled-jsx/**' +'plugin: transform-imports': + - changed-files: + - any-glob-to-any-file: 'packages/transform-imports/**' + 'package: oxc-unshadowed-visitor': - changed-files: - any-glob-to-any-file: 'packages/oxc-unshadowed-visitor/**' diff --git a/README.md b/README.md index 8ca796a..3acb906 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Official Rolldown plugins - [`@rolldown/plugin-emotion`](https://github.com/rolldown/plugins/tree/main/packages/emotion) ([![NPM version][badge-npm-version-emotion]][url-npm-emotion]): minification and optimization of Emotion styles - [`@rolldown/plugin-jsx-remove-attributes`](https://github.com/rolldown/plugins/tree/main/packages/jsx-remove-attributes) ([![NPM version][badge-npm-version-jsx-remove-attributes]][url-npm-jsx-remove-attributes]): remove JSX attributes (e.g. data-testid) - [`@rolldown/plugin-styled-jsx`](https://github.com/rolldown/plugins/tree/main/packages/styled-jsx) ([![NPM version][badge-npm-version-styled-jsx]][url-npm-styled-jsx]): Rolldown plugin for styled-jsx CSS scoping +- [`@rolldown/plugin-transform-imports`](https://github.com/rolldown/plugins/tree/main/packages/transform-imports) ([![NPM version][badge-npm-version-transform-imports]][url-npm-transform-imports]): transform imports/exports to barrel files ### Other Packages @@ -38,9 +39,11 @@ Official Rolldown plugins [badge-npm-version-emotion]: https://img.shields.io/npm/v/@rolldown/plugin-emotion?color=brightgreen [badge-npm-version-jsx-remove-attributes]: https://img.shields.io/npm/v/@rolldown/plugin-jsx-remove-attributes?color=brightgreen [badge-npm-version-styled-jsx]: https://img.shields.io/npm/v/@rolldown/plugin-styled-jsx?color=brightgreen +[badge-npm-version-transform-imports]: https://img.shields.io/npm/v/@rolldown/plugin-transform-imports?color=brightgreen [badge-npm-version-oxc-unshadowed-visitor]: https://img.shields.io/npm/v/oxc-unshadowed-visitor?color=brightgreen [url-npm-babel]: https://npmx.dev/package/@rolldown/plugin-babel [url-npm-emotion]: https://npmx.dev/package/@rolldown/plugin-emotion [url-npm-jsx-remove-attributes]: https://npmx.dev/package/@rolldown/plugin-jsx-remove-attributes [url-npm-styled-jsx]: https://npmx.dev/package/@rolldown/plugin-styled-jsx +[url-npm-transform-imports]: https://npmx.dev/package/@rolldown/plugin-transform-imports [url-npm-oxc-unshadowed-visitor]: https://npmx.dev/package/oxc-unshadowed-visitor diff --git a/packages/transform-imports/CHANGELOG.md b/packages/transform-imports/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/transform-imports/README.md b/packages/transform-imports/README.md new file mode 100644 index 0000000..2325e28 --- /dev/null +++ b/packages/transform-imports/README.md @@ -0,0 +1,203 @@ +# @rolldown/plugin-transform-imports [![npm](https://img.shields.io/npm/v/@rolldown/plugin-transform-imports.svg)](https://npmx.dev/package/@rolldown/plugin-transform-imports) + +Rolldown plugin for transforming imports and re-exports from barrel files into individual module imports. This improves performance by avoiding resolving all of imports in the barrel file. For example, it rewrites `import { map } from "lodash"` with `import map from "lodash/map"`. + +## Install + +```bash +pnpm add -D @rolldown/plugin-transform-imports +``` + +## Usage + +```js +import transformImports from '@rolldown/plugin-transform-imports' + +export default { + plugins: [ + transformImports({ + modules: { + lodash: { + transform: 'lodash/{{member}}', + }, + }, + }), + ], +} +``` + +This transforms: + +```js +import { map, filter } from 'lodash' +``` + +Into: + +```js +import map from 'lodash/map' +import filter from 'lodash/filter' +``` + +## Options + +### `modules` + +- **Type:** `Record` + +A map of module names (or regex patterns) to their transform configuration. + +Module names are converted to regex patterns: `lodash` matches exactly `lodash`, while `^lodash(/.*)?$` allows custom regex patterns (must start with `^` and end with `$`). + +## TransformConfig + +### `transform` + +- **Type:** `string | [pattern: string, template: string][]` +- **Required** + +The path template for transformed imports. Use `{{member}}` as a placeholder for the imported member name. + +**String form:** + +```js +{ + transform: 'lodash/{{member}}' +} +// import { map } from 'lodash' → import map from 'lodash/map' +``` + +**Array of tuples form** for pattern-based routing: + +```js +{ + transform: [ + ['someFunc', 'some-lib/some-module'], + ['*', 'some-lib/{{member}}'], + ] +} +``` + +Each tuple is `[pattern, template]`. Patterns are treated as regex (except `*` which matches anything). The first matching pattern wins. + +### `preventFullImport` + +- **Type:** `boolean` +- **Default:** `false` + +When `true`, throws an error on namespace imports (`import * as X`) and side-effect imports (`import 'mod'`). + +### `skipDefaultConversion` + +- **Type:** `boolean` +- **Default:** `false` + +When `true`, keeps the import as a named import instead of converting to a default import: + +```js +// skipDefaultConversion: false (default) +import { map } from 'lodash' +import map from 'lodash/map' // output + +// skipDefaultConversion: true +import { map } from 'lodash' +import { map } from 'lodash/map' // output +``` + +### `handleDefaultImport` + +- **Type:** `boolean` +- **Default:** `false` + +When `true`, transforms default imports using the local name as the member: + +```js +// handleDefaultImport: true +import myMap from 'lodash' +import myMap from 'lodash/myMap' // output +``` + +### `handleNamespaceImport` + +- **Type:** `boolean` +- **Default:** `false` + +When `true`, transforms namespace imports using the local name as the member: + +```js +// handleNamespaceImport: true +import * as myMap from 'lodash' +import * as myMap from 'lodash/myMap' // output +``` + +## Template Syntax + +Templates use `{{...}}` placeholders with the following variables: + +### Variables + +- **`{{member}}`** — the imported member name +- **`{{matches.[N]}}`** — capture group from the module name regex +- **`{{memberMatches.[N]}}`** — capture group from the member pattern (tuple form only) + +### Case Transforms + +Apply a case transform by prefixing the variable: + +- **`{{camelCase member}}`** — `MyComponent` → `myComponent` +- **`{{kebabCase member}}`** — `MyComponent` → `my-component` +- **`{{snakeCase member}}`** — `MyComponent` → `my_component` +- **`{{upperCase member}}`** — `map` → `MAP` + +### Example with regex capture groups + +```js +{ + modules: { + '^my-lib/(.+)$': { + transform: 'my-lib/dist/{{matches.[1]}}/{{kebabCase member}}', + }, + }, +} +// import { MyButton } from 'my-lib/components' +// → import MyButton from 'my-lib/dist/components/my-button' +``` + +## Re-exports + +The plugin also transforms re-exports: + +```js +// input +export { map, filter } from 'lodash' +// output +export { default as map } from 'lodash/map' +export { default as filter } from 'lodash/filter' +``` + +With `skipDefaultConversion: true`: + +```js +// input +export { map, filter } from 'lodash' +// output +export { map } from 'lodash/map' +export { filter } from 'lodash/filter' +``` + +## Dynamic Imports + +Static dynamic imports are also transformed: + +```js +const mod = await import('lodash') +// source is rewritten based on the transform template +``` + +## License + +MIT + +## Credits + +The implementation is based on [swc-project/plugins/packages/transform-imports](https://github.com/swc-project/plugins/tree/main/packages/transform-imports) ([Apache License 2.0](https://github.com/swc-project/plugins/blob/main/LICENSE)). Test cases are also adapted from it. diff --git a/packages/transform-imports/package.json b/packages/transform-imports/package.json new file mode 100644 index 0000000..439a8db --- /dev/null +++ b/packages/transform-imports/package.json @@ -0,0 +1,61 @@ +{ + "name": "@rolldown/plugin-transform-imports", + "version": "0.1.0", + "description": "Rolldown plugin for transforming import/exports to barrel files", + "keywords": [ + "imports", + "modularize", + "plugin", + "rolldown", + "rolldown-plugin", + "transform-imports", + "tree-shaking" + ], + "homepage": "https://github.com/rolldown/plugins/tree/main/packages/transform-imports#readme", + "bugs": { + "url": "https://github.com/rolldown/plugins/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rolldown/plugins.git", + "directory": "packages/transform-imports" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": "./dist/index.mjs", + "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", + "test": "vitest --project transform-imports", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "rolldown-string": "^0.3.0" + }, + "devDependencies": { + "rolldown": "^1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "peerDependencies": { + "rolldown": "^1.0.0-rc.13", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "engines": { + "node": ">=22.12.0 || ^24.0.0" + }, + "compatiblePackages": { + "schemaVersion": 1, + "rollup": { + "type": "incompatible", + "reason": "Uses Rolldown-specific APIs" + } + } +} diff --git a/packages/transform-imports/src/index.ts b/packages/transform-imports/src/index.ts new file mode 100644 index 0000000..f5bb0bc --- /dev/null +++ b/packages/transform-imports/src/index.ts @@ -0,0 +1,271 @@ +import { withMagicString, type RolldownString } from 'rolldown-string' +import type { Plugin } from 'rolldown' +import { Visitor } from 'rolldown/utils' +import type { ESTree } from 'rolldown/utils' +import type { TransformImportsOptions, TransformConfig, TransformPattern } from './types.js' +import { processTemplate } from './template.js' + +export type { + TransformImportsOptions, + TransformConfig, + PluginConfig, + TransformPattern, +} from './types.js' + +function getName(node: ESTree.ModuleExportName): string { + return node.type === 'Literal' ? node.value : node.name +} + +interface CompiledModule { + regex: RegExp + config: TransformConfig +} + +function compileModulePatterns(modules: Record): CompiledModule[] { + return Object.entries(modules).map(([pattern, config]) => ({ + regex: + pattern.startsWith('^') && pattern.endsWith('$') + ? new RegExp(pattern) + : new RegExp(`^${pattern}$`), + config, + })) +} + +function buildCodeFilter(modules: Record): RegExp | undefined { + const literals = Object.keys(modules) + .map((pattern) => { + // Extract literal prefix before first regex metacharacter + const match = pattern.match(/^[^\\?*+[\](){}|^$.]+/) + return match ? match[0] : '' + }) + .filter(Boolean) + if (literals.length === 0) return undefined + + const escaped = literals.map((lit) => lit.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + return new RegExp(escaped.join('|')) +} + +function findMatchingModule( + source: string, + compiledModules: CompiledModule[], +): { config: TransformConfig; matches: RegExpMatchArray } | null { + for (const { regex, config } of compiledModules) { + const match = source.match(regex) + if (match) return { config, matches: match } + } + return null +} + +function resolveTransformPath( + transform: TransformPattern, + member: string, + moduleMatches: RegExpMatchArray, +): string { + let result: string + + if (typeof transform === 'string') { + result = processTemplate(transform, member, moduleMatches, null) + } else { + result = resolveFromTuples(transform, member, moduleMatches) + } + + // Clean up consecutive slashes from empty template variables + return result.replace(/\/\/+/g, '/') +} + +function resolveFromTuples( + tuples: [string, string][], + member: string, + moduleMatches: RegExpMatchArray, +): string { + for (const [pattern, template] of tuples) { + if (pattern === '*') { + return processTemplate(template, member, moduleMatches, null) + } + // Treat all non-wildcard patterns as regex + const regex = new RegExp(`^${pattern}$`) + const match = member.match(regex) + if (match) { + return processTemplate(template, member, moduleMatches, match) + } + } + + return member +} + +function processImportDeclaration( + s: RolldownString, + decl: ESTree.ImportDeclaration, + compiledModules: CompiledModule[], +): void { + const source = decl.source.value + const match = findMatchingModule(source, compiledModules) + if (!match) return + + const { config, matches } = match + const specifiers = decl.specifiers + + // Check preventFullImport + if (config.preventFullImport) { + if (specifiers.some((sp) => sp.type === 'ImportNamespaceSpecifier')) { + throw new Error(`preventFullImport: namespace import of "${source}" is not allowed`) + } + if (specifiers.length === 0) { + throw new Error(`preventFullImport: side-effect import of "${source}" is not allowed`) + } + } + + // Side-effect import with no specifiers: rewrite source path + if (specifiers.length === 0) { + const transformedPath = resolveTransformPath(config.transform, '', matches) + s.update(decl.source.start, decl.source.end, `"${transformedPath}"`) + return + } + + // Check if any specifiers will actually be transformed + const hasTransformed = specifiers.some( + (sp) => + sp.type === 'ImportSpecifier' || + (sp.type === 'ImportDefaultSpecifier' && config.handleDefaultImport) || + (sp.type === 'ImportNamespaceSpecifier' && config.handleNamespaceImport), + ) + + // If nothing will be transformed, leave the import unchanged + if (!hasTransformed) return + + const newImports: string[] = [] + for (const spec of specifiers) { + if (spec.type === 'ImportSpecifier') { + const imported = getName(spec.imported) + const local = getName(spec.local) + const transformedPath = resolveTransformPath(config.transform, imported, matches) + + if (config.skipDefaultConversion) { + newImports.push(`import { ${local} } from "${transformedPath}";`) + } else { + newImports.push(`import ${local} from "${transformedPath}";`) + } + } else if (spec.type === 'ImportDefaultSpecifier') { + if (config.handleDefaultImport) { + const local = getName(spec.local) + const transformedPath = resolveTransformPath(config.transform, local, matches) + newImports.push(`import ${local} from "${transformedPath}";`) + } + // Non-handled defaults in mixed imports are dropped + } else if (spec.type === 'ImportNamespaceSpecifier') { + if (config.handleNamespaceImport) { + const local = getName(spec.local) + const transformedPath = resolveTransformPath(config.transform, local, matches) + newImports.push(`import * as ${local} from "${transformedPath}";`) + } + // Non-handled namespaces in mixed imports are dropped + } + } + s.update(decl.start, decl.end, newImports.join('\n')) +} + +function processExportNamedDeclaration( + s: RolldownString, + decl: ESTree.ExportNamedDeclaration, + compiledModules: CompiledModule[], +): void { + if (!decl.source) return + + const source = decl.source.value + const match = findMatchingModule(source, compiledModules) + if (!match) return + + const { config, matches } = match + + const reexports: string[] = [] + for (const spec of decl.specifiers) { + const local = getName(spec.local) + const exported = getName(spec.exported) + const transformedPath = resolveTransformPath(config.transform, local, matches) + if (config.skipDefaultConversion) { + reexports.push(`export { ${exported} } from "${transformedPath}";`) + } else { + reexports.push(`export { default as ${exported} } from "${transformedPath}";`) + } + } + s.update(decl.start, decl.end, reexports.join('\n')) +} + +function processExportAllDeclaration( + s: RolldownString, + decl: ESTree.ExportAllDeclaration, + compiledModules: CompiledModule[], +): void { + const source = decl.source.value + const match = findMatchingModule(source, compiledModules) + if (!match) return + + const { config, matches } = match + const transformedPath = resolveTransformPath(config.transform, '', matches) + s.update(decl.source.start, decl.source.end, `"${transformedPath}"`) +} + +function getStaticImportExpressionSource(source: ESTree.Expression): string | null { + if (source.type === 'Literal' && typeof source.value === 'string') { + return source.value + } + if (source.type === 'TemplateLiteral' && source.expressions.length === 0) { + const [quasi] = source.quasis + if (quasi) return quasi.value.cooked ?? quasi.value.raw + return '' + } + return null +} + +function processImportExpression( + s: RolldownString, + decl: ESTree.ImportExpression, + compiledModules: CompiledModule[], +): void { + const source = getStaticImportExpressionSource(decl.source) + if (source == null) return + + const match = findMatchingModule(source, compiledModules) + if (!match) return + + const { config, matches } = match + const transformedPath = resolveTransformPath(config.transform, '', matches) + s.update(decl.source.start, decl.source.end, `"${transformedPath}"`) +} + +export function transformImportsPlugin(options: TransformImportsOptions): Plugin { + const compiledModules = compileModulePatterns(options.modules) + const codeFilter = buildCodeFilter(options.modules) + + return { + name: 'rolldown-plugin-transform-imports', + + transform: { + filter: { + id: /\.[jt]sx?$/, + ...(codeFilter && { code: { include: codeFilter } }), + }, + + handler: withMagicString(function (this, s, _id, meta) { + const ast = meta?.ast ?? this.parse(s.original) + + new Visitor({ + ImportDeclaration(node: ESTree.ImportDeclaration) { + processImportDeclaration(s, node, compiledModules) + }, + ExportNamedDeclaration(node: ESTree.ExportNamedDeclaration) { + processExportNamedDeclaration(s, node, compiledModules) + }, + ExportAllDeclaration(node: ESTree.ExportAllDeclaration) { + processExportAllDeclaration(s, node, compiledModules) + }, + ImportExpression(node: ESTree.ImportExpression) { + processImportExpression(s, node, compiledModules) + }, + }).visit(ast) + }), + }, + } +} + +export default transformImportsPlugin diff --git a/packages/transform-imports/src/template.ts b/packages/transform-imports/src/template.ts new file mode 100644 index 0000000..fba14c3 --- /dev/null +++ b/packages/transform-imports/src/template.ts @@ -0,0 +1,79 @@ +function splitWords(str: string): string[] { + return str + .replace(/([a-z0-9])([A-Z])/g, '$1\0$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1\0$2') + .split('\0') +} + +function camelCase(str: string): string { + const words = splitWords(str) + return words + .map((w, i) => + i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), + ) + .join('') +} + +function kebabCase(str: string): string { + return splitWords(str) + .map((w) => w.toLowerCase()) + .join('-') +} + +function snakeCase(str: string): string { + return splitWords(str) + .map((w) => w.toLowerCase()) + .join('_') +} + +function upperCase(str: string): string { + return str.toUpperCase() +} + +const caseTransforms: Record string> = { + camelCase, + kebabCase, + snakeCase, + upperCase, +} + +function resolveVariable( + variable: string, + member: string, + matches: RegExpMatchArray | null, + memberMatches: RegExpMatchArray | null, +): string { + if (variable === 'member') return member + + const matchesResult = variable.match(/^matches\.\[(\d+)\]$/) + if (matchesResult) { + return matches?.[Number.parseInt(matchesResult[1])] ?? '' + } + + const memberMatchesResult = variable.match(/^memberMatches\.\[(\d+)\]$/) + if (memberMatchesResult) { + return memberMatches?.[Number.parseInt(memberMatchesResult[1])] ?? '' + } + + return variable +} + +export function processTemplate( + template: string, + member: string, + matches: RegExpMatchArray | null, + memberMatches: RegExpMatchArray | null, +): string { + return template.replace(/\{\{(.*?)\}\}/g, (_match, expr: string) => { + const parts = expr.trim().split(/\s+/) + if (parts.length === 2) { + const transform = caseTransforms[parts[0]] + const value = resolveVariable(parts[1], member, matches, memberMatches) + return transform ? transform(value) : value + } + if (parts.length === 1) { + return resolveVariable(parts[0], member, matches, memberMatches) + } + return _match + }) +} diff --git a/packages/transform-imports/src/types.ts b/packages/transform-imports/src/types.ts new file mode 100644 index 0000000..52a94cd --- /dev/null +++ b/packages/transform-imports/src/types.ts @@ -0,0 +1,20 @@ +export type TransformPattern = string | [pattern: string, template: string][] + +export interface TransformConfig { + /** Template with {{member}} placeholder: "lodash/{{member}}" or array of [pattern, template] tuples */ + transform: TransformPattern + /** Throw error on `import * as X` or `import 'mod'` */ + preventFullImport?: boolean + /** Keep `import { X }` instead of `import X` */ + skipDefaultConversion?: boolean + /** Transform default imports using local name as member (default: false) */ + handleDefaultImport?: boolean + /** Transform namespace imports using local name as member (default: false) */ + handleNamespaceImport?: boolean +} + +export type PluginConfig = Record + +export interface TransformImportsOptions { + modules: PluginConfig +} diff --git a/packages/transform-imports/tests/fixtures/antd/config.json b/packages/transform-imports/tests/fixtures/antd/config.json new file mode 100644 index 0000000..cb0a3ba --- /dev/null +++ b/packages/transform-imports/tests/fixtures/antd/config.json @@ -0,0 +1,6 @@ +{ + "antd": { + "transform": "antd/lib/{{ kebabCase member }}", + "style": "style" + } +} diff --git a/packages/transform-imports/tests/fixtures/antd/input.js b/packages/transform-imports/tests/fixtures/antd/input.js new file mode 100644 index 0000000..d1dcaec --- /dev/null +++ b/packages/transform-imports/tests/fixtures/antd/input.js @@ -0,0 +1 @@ +export { Button, Card, InputNumber } from 'antd'; diff --git a/packages/transform-imports/tests/fixtures/antd/output.js b/packages/transform-imports/tests/fixtures/antd/output.js new file mode 100644 index 0000000..f1b7846 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/antd/output.js @@ -0,0 +1,4 @@ +import Button from "antd/lib/button"; +import Card from "antd/lib/card"; +import InputNumber from "antd/lib/input-number"; +export { Button, Card, InputNumber }; diff --git a/packages/transform-imports/tests/fixtures/camel-case/config.json b/packages/transform-imports/tests/fixtures/camel-case/config.json new file mode 100644 index 0000000..53523b9 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/camel-case/config.json @@ -0,0 +1,6 @@ +{ + "my-library-2": { + "transform": "my-library-2/{{ camelCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/camel-case/input.js b/packages/transform-imports/tests/fixtures/camel-case/input.js new file mode 100644 index 0000000..3acb6ff --- /dev/null +++ b/packages/transform-imports/tests/fixtures/camel-case/input.js @@ -0,0 +1,4 @@ +// Test camelCase transformation: MyModule -> myModule +import { MyModule, SomeWidget, AnotherComponent } from "my-library-2"; + +console.log(MyModule, SomeWidget, AnotherComponent); diff --git a/packages/transform-imports/tests/fixtures/camel-case/output.js b/packages/transform-imports/tests/fixtures/camel-case/output.js new file mode 100644 index 0000000..53dbe20 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/camel-case/output.js @@ -0,0 +1,6 @@ +import { MyModule } from "my-library-2/myModule"; +import { SomeWidget } from "my-library-2/someWidget"; +import { AnotherComponent } from "my-library-2/anotherComponent"; +//#region virtual:entry.ts +console.log(MyModule, SomeWidget, AnotherComponent); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/default/config.json b/packages/transform-imports/tests/fixtures/default/config.json new file mode 100644 index 0000000..17ab549 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/default/config.json @@ -0,0 +1,6 @@ +{ + "my-(module-namespace|default|mixed-(named|star))": { + "transform": "transformed-{{matches.[1]}}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/default/input.js b/packages/transform-imports/tests/fixtures/default/input.js new file mode 100644 index 0000000..3a59662 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/default/input.js @@ -0,0 +1,2 @@ +import defaultExport from "my-default"; +export { defaultExport }; diff --git a/packages/transform-imports/tests/fixtures/default/output.js b/packages/transform-imports/tests/fixtures/default/output.js new file mode 100644 index 0000000..3a59662 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/default/output.js @@ -0,0 +1,2 @@ +import defaultExport from "my-default"; +export { defaultExport }; diff --git a/packages/transform-imports/tests/fixtures/dynamic-imports/config.json b/packages/transform-imports/tests/fixtures/dynamic-imports/config.json new file mode 100644 index 0000000..b1a9f9e --- /dev/null +++ b/packages/transform-imports/tests/fixtures/dynamic-imports/config.json @@ -0,0 +1,5 @@ +{ + "antd": { + "transform": "antd/es/{{member}}" + } +} diff --git a/packages/transform-imports/tests/fixtures/dynamic-imports/input.js b/packages/transform-imports/tests/fixtures/dynamic-imports/input.js new file mode 100644 index 0000000..287a264 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/dynamic-imports/input.js @@ -0,0 +1,11 @@ +await import ('antd'); +await import (`antd`); +await import(`antd/${x}`); + +if (ok) { + await import ('antd'); +} + +foo(import ('antd')); +await import ('antd', { with: { type: 'json' } }); +await import ('antd/es/button'); diff --git a/packages/transform-imports/tests/fixtures/dynamic-imports/output.js b/packages/transform-imports/tests/fixtures/dynamic-imports/output.js new file mode 100644 index 0000000..8ebd028 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/dynamic-imports/output.js @@ -0,0 +1,9 @@ +//#region virtual:entry.ts +await import("antd/es/"); +await import("antd/es/"); +await import(`antd/${x}`); +if (ok) await import("antd/es/"); +foo(import("antd/es/")); +await import("antd/es/", { with: { type: "json" } }); +await import("antd/es/button"); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/handle-default-import/config.json b/packages/transform-imports/tests/fixtures/handle-default-import/config.json new file mode 100644 index 0000000..b366064 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-import/config.json @@ -0,0 +1,6 @@ +{ + "my-lib": { + "transform": "my-lib/{{member}}", + "handleDefaultImport": true + } +} diff --git a/packages/transform-imports/tests/fixtures/handle-default-import/input.js b/packages/transform-imports/tests/fixtures/handle-default-import/input.js new file mode 100644 index 0000000..2b12eb0 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-import/input.js @@ -0,0 +1,2 @@ +import Button from "my-lib"; +console.log(Button); diff --git a/packages/transform-imports/tests/fixtures/handle-default-import/output.js b/packages/transform-imports/tests/fixtures/handle-default-import/output.js new file mode 100644 index 0000000..f0a1066 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-import/output.js @@ -0,0 +1,4 @@ +import Button from "my-lib/Button"; +//#region virtual:entry.ts +console.log(Button); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/handle-default-mixed/config.json b/packages/transform-imports/tests/fixtures/handle-default-mixed/config.json new file mode 100644 index 0000000..b366064 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-mixed/config.json @@ -0,0 +1,6 @@ +{ + "my-lib": { + "transform": "my-lib/{{member}}", + "handleDefaultImport": true + } +} diff --git a/packages/transform-imports/tests/fixtures/handle-default-mixed/input.js b/packages/transform-imports/tests/fixtures/handle-default-mixed/input.js new file mode 100644 index 0000000..05bf6a9 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-mixed/input.js @@ -0,0 +1,2 @@ +import Default, { Named } from "my-lib"; +console.log(Default, Named); diff --git a/packages/transform-imports/tests/fixtures/handle-default-mixed/output.js b/packages/transform-imports/tests/fixtures/handle-default-mixed/output.js new file mode 100644 index 0000000..b54ec65 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-default-mixed/output.js @@ -0,0 +1,5 @@ +import Default from "my-lib/Default"; +import Named from "my-lib/Named"; +//#region virtual:entry.ts +console.log(Default, Named); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/handle-namespace-import/config.json b/packages/transform-imports/tests/fixtures/handle-namespace-import/config.json new file mode 100644 index 0000000..71be839 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-namespace-import/config.json @@ -0,0 +1,6 @@ +{ + "my-lib": { + "transform": "my-lib/{{member}}", + "handleNamespaceImport": true + } +} diff --git a/packages/transform-imports/tests/fixtures/handle-namespace-import/input.js b/packages/transform-imports/tests/fixtures/handle-namespace-import/input.js new file mode 100644 index 0000000..b3ba4f6 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-namespace-import/input.js @@ -0,0 +1,2 @@ +import * as Button from "my-lib"; +console.log(Button); diff --git a/packages/transform-imports/tests/fixtures/handle-namespace-import/output.js b/packages/transform-imports/tests/fixtures/handle-namespace-import/output.js new file mode 100644 index 0000000..234c52f --- /dev/null +++ b/packages/transform-imports/tests/fixtures/handle-namespace-import/output.js @@ -0,0 +1,4 @@ +import * as Button from "my-lib/Button"; +//#region virtual:entry.ts +console.log(Button); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/kebab-case/config.json b/packages/transform-imports/tests/fixtures/kebab-case/config.json new file mode 100644 index 0000000..b4405b9 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/kebab-case/config.json @@ -0,0 +1,6 @@ +{ + "my-library-3": { + "transform": "my-library-3/{{ kebabCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/kebab-case/input.js b/packages/transform-imports/tests/fixtures/kebab-case/input.js new file mode 100644 index 0000000..197c303 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/kebab-case/input.js @@ -0,0 +1,4 @@ +// Test kebabCase transformation: MyModule -> my-module +import { AnotherModule, MyFancyComponent, SomeWidget } from "my-library-3"; + +console.log(AnotherModule, MyFancyComponent, SomeWidget); diff --git a/packages/transform-imports/tests/fixtures/kebab-case/output.js b/packages/transform-imports/tests/fixtures/kebab-case/output.js new file mode 100644 index 0000000..4c42cb3 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/kebab-case/output.js @@ -0,0 +1,6 @@ +import { AnotherModule } from "my-library-3/another-module"; +import { MyFancyComponent } from "my-library-3/my-fancy-component"; +import { SomeWidget } from "my-library-3/some-widget"; +//#region virtual:entry.ts +console.log(AnotherModule, MyFancyComponent, SomeWidget); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/mapping-export/config.json b/packages/transform-imports/tests/fixtures/mapping-export/config.json new file mode 100644 index 0000000..8e1dc69 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping-export/config.json @@ -0,0 +1,12 @@ +{ + "my-library-4": { + "transform": [ + ["foo", "my-library-4/this_is_foo"], + ["bar", "my-library-4/bar"], + ["use(\\w*)", "my-library-4/{{ kebabCase member }}/{{ kebabCase memberMatches.[1] }}"], + ["(\\w*)Icon", "my-library-4/{{ kebabCase memberMatches.[1] }}"], + ["*", "my-library-4/{{ upperCase member }}"] + ], + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/mapping-export/input.js b/packages/transform-imports/tests/fixtures/mapping-export/input.js new file mode 100644 index 0000000..ed144fa --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping-export/input.js @@ -0,0 +1,4 @@ +export { foo, bar } from "my-library-4"; +export { otherImport } from "my-library-4"; +export { useSomeAPI } from "my-library-4"; +export { ArrowUpIcon } from "my-library-4"; diff --git a/packages/transform-imports/tests/fixtures/mapping-export/output.js b/packages/transform-imports/tests/fixtures/mapping-export/output.js new file mode 100644 index 0000000..6a3f51b --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping-export/output.js @@ -0,0 +1,6 @@ +import { foo } from "my-library-4/this_is_foo"; +import { bar } from "my-library-4/bar"; +import { otherImport } from "my-library-4/OTHERIMPORT"; +import { useSomeAPI } from "my-library-4/use-some-api/some-api"; +import { ArrowUpIcon } from "my-library-4/arrow-up"; +export { ArrowUpIcon, bar, foo, otherImport, useSomeAPI }; diff --git a/packages/transform-imports/tests/fixtures/mapping/config.json b/packages/transform-imports/tests/fixtures/mapping/config.json new file mode 100644 index 0000000..8e1dc69 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping/config.json @@ -0,0 +1,12 @@ +{ + "my-library-4": { + "transform": [ + ["foo", "my-library-4/this_is_foo"], + ["bar", "my-library-4/bar"], + ["use(\\w*)", "my-library-4/{{ kebabCase member }}/{{ kebabCase memberMatches.[1] }}"], + ["(\\w*)Icon", "my-library-4/{{ kebabCase memberMatches.[1] }}"], + ["*", "my-library-4/{{ upperCase member }}"] + ], + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/mapping/input.js b/packages/transform-imports/tests/fixtures/mapping/input.js new file mode 100644 index 0000000..f9860cf --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping/input.js @@ -0,0 +1,6 @@ +import { foo, bar } from "my-library-4"; +import { otherImport } from "my-library-4"; +import { useSomeAPI } from "my-library-4"; +import { ArrowUpIcon } from "my-library-4"; + +console.log(foo, bar, otherImport, useSomeAPI, ArrowUpIcon); diff --git a/packages/transform-imports/tests/fixtures/mapping/output.js b/packages/transform-imports/tests/fixtures/mapping/output.js new file mode 100644 index 0000000..f416085 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mapping/output.js @@ -0,0 +1,8 @@ +import { foo } from "my-library-4/this_is_foo"; +import { bar } from "my-library-4/bar"; +import { otherImport } from "my-library-4/OTHERIMPORT"; +import { useSomeAPI } from "my-library-4/use-some-api/some-api"; +import { ArrowUpIcon } from "my-library-4/arrow-up"; +//#region virtual:entry.ts +console.log(foo, bar, otherImport, useSomeAPI, ArrowUpIcon); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/member-import/config.json b/packages/transform-imports/tests/fixtures/member-import/config.json new file mode 100644 index 0000000..c983152 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/member-import/config.json @@ -0,0 +1,5 @@ +{ + "react-bootstrap": { + "transform": "react-bootstrap/lib/{{member}}" + } +} diff --git a/packages/transform-imports/tests/fixtures/member-import/input.js b/packages/transform-imports/tests/fixtures/member-import/input.js new file mode 100644 index 0000000..ac572cc --- /dev/null +++ b/packages/transform-imports/tests/fixtures/member-import/input.js @@ -0,0 +1,3 @@ +import { Grid, Row as row } from 'react-bootstrap'; + +console.log(Grid, row); diff --git a/packages/transform-imports/tests/fixtures/member-import/output.js b/packages/transform-imports/tests/fixtures/member-import/output.js new file mode 100644 index 0000000..d12bb22 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/member-import/output.js @@ -0,0 +1,5 @@ +import Grid from "react-bootstrap/lib/Grid"; +import row from "react-bootstrap/lib/Row"; +//#region virtual:entry.ts +console.log(Grid, row); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/mixed-default/config.json b/packages/transform-imports/tests/fixtures/mixed-default/config.json new file mode 100644 index 0000000..17ab549 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mixed-default/config.json @@ -0,0 +1,6 @@ +{ + "my-(module-namespace|default|mixed-(named|star))": { + "transform": "transformed-{{matches.[1]}}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/mixed-default/input.js b/packages/transform-imports/tests/fixtures/mixed-default/input.js new file mode 100644 index 0000000..f9118cb --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mixed-default/input.js @@ -0,0 +1,3 @@ +import defaultExport, { someNamedExport } from "my-mixed-named"; +import otherDefaultExport, * as starExport from "my-mixed-star"; +console.log(defaultExport, someNamedExport, otherDefaultExport, starExport); diff --git a/packages/transform-imports/tests/fixtures/mixed-default/output.js b/packages/transform-imports/tests/fixtures/mixed-default/output.js new file mode 100644 index 0000000..8815218 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/mixed-default/output.js @@ -0,0 +1,6 @@ +import { someNamedExport } from "transformed-mixed-named"; +import * as starExport from "my-mixed-star"; +import otherDefaultExport from "my-mixed-star"; +//#region virtual:entry.ts +console.log(defaultExport, someNamedExport, otherDefaultExport, starExport); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/namespace-export/config.json b/packages/transform-imports/tests/fixtures/namespace-export/config.json new file mode 100644 index 0000000..2f9bba3 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace-export/config.json @@ -0,0 +1,7 @@ +{ + "my-(module-namespace|default|mixed-(named|star))": { + "transform": "transformed-{{matches.[1]}}", + "skipDefaultConversion": true, + "handleNamespaceImport": true + } +} diff --git a/packages/transform-imports/tests/fixtures/namespace-export/input.js b/packages/transform-imports/tests/fixtures/namespace-export/input.js new file mode 100644 index 0000000..f18be5c --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace-export/input.js @@ -0,0 +1 @@ +export * as someModule from "my-module-namespace"; diff --git a/packages/transform-imports/tests/fixtures/namespace-export/output.js b/packages/transform-imports/tests/fixtures/namespace-export/output.js new file mode 100644 index 0000000..0958cbd --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace-export/output.js @@ -0,0 +1,2 @@ +import * as someModule from "transformed-module-namespace"; +export { someModule }; diff --git a/packages/transform-imports/tests/fixtures/namespace/config.json b/packages/transform-imports/tests/fixtures/namespace/config.json new file mode 100644 index 0000000..17ab549 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace/config.json @@ -0,0 +1,6 @@ +{ + "my-(module-namespace|default|mixed-(named|star))": { + "transform": "transformed-{{matches.[1]}}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/namespace/input.js b/packages/transform-imports/tests/fixtures/namespace/input.js new file mode 100644 index 0000000..a73487f --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace/input.js @@ -0,0 +1,2 @@ +import * as someModule from "my-module-namespace"; +export { someModule }; diff --git a/packages/transform-imports/tests/fixtures/namespace/output.js b/packages/transform-imports/tests/fixtures/namespace/output.js new file mode 100644 index 0000000..a73487f --- /dev/null +++ b/packages/transform-imports/tests/fixtures/namespace/output.js @@ -0,0 +1,2 @@ +import * as someModule from "my-module-namespace"; +export { someModule }; diff --git a/packages/transform-imports/tests/fixtures/pascal-case/config.json b/packages/transform-imports/tests/fixtures/pascal-case/config.json new file mode 100644 index 0000000..5b6754b --- /dev/null +++ b/packages/transform-imports/tests/fixtures/pascal-case/config.json @@ -0,0 +1,6 @@ +{ + "test-pascal-case": { + "transform": "test-pascal-case/{{ member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/pascal-case/input.js b/packages/transform-imports/tests/fixtures/pascal-case/input.js new file mode 100644 index 0000000..079a937 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/pascal-case/input.js @@ -0,0 +1,4 @@ +// Test that member name is preserved in path when using member template +import { myComponent, anotherWidget } from "test-pascal-case"; + +console.log(myComponent, anotherWidget); diff --git a/packages/transform-imports/tests/fixtures/pascal-case/output.js b/packages/transform-imports/tests/fixtures/pascal-case/output.js new file mode 100644 index 0000000..9b786a5 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/pascal-case/output.js @@ -0,0 +1,5 @@ +import { myComponent } from "test-pascal-case/myComponent"; +import { anotherWidget } from "test-pascal-case/anotherWidget"; +//#region virtual:entry.ts +console.log(myComponent, anotherWidget); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/regex-export/config.json b/packages/transform-imports/tests/fixtures/regex-export/config.json new file mode 100644 index 0000000..bfe8cee --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex-export/config.json @@ -0,0 +1,5 @@ +{ + "my-library/?(((\\w*)?/?)*)": { + "transform": "my-library/{{ matches.[1] }}/{{member}}" + } +} diff --git a/packages/transform-imports/tests/fixtures/regex-export/input.js b/packages/transform-imports/tests/fixtures/regex-export/input.js new file mode 100644 index 0000000..992d999 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex-export/input.js @@ -0,0 +1,3 @@ +export { MyModule } from "my-library"; +export { App } from "my-library/components"; +export { Header, Footer } from "my-library/components/App"; diff --git a/packages/transform-imports/tests/fixtures/regex-export/output.js b/packages/transform-imports/tests/fixtures/regex-export/output.js new file mode 100644 index 0000000..eb511d8 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex-export/output.js @@ -0,0 +1,5 @@ +import MyModule from "my-library/MyModule"; +import App from "my-library/components/App"; +import Header from "my-library/components/App/Header"; +import Footer from "my-library/components/App/Footer"; +export { App, Footer, Header, MyModule }; diff --git a/packages/transform-imports/tests/fixtures/regex/config.json b/packages/transform-imports/tests/fixtures/regex/config.json new file mode 100644 index 0000000..bfe8cee --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex/config.json @@ -0,0 +1,5 @@ +{ + "my-library/?(((\\w*)?/?)*)": { + "transform": "my-library/{{ matches.[1] }}/{{member}}" + } +} diff --git a/packages/transform-imports/tests/fixtures/regex/input.js b/packages/transform-imports/tests/fixtures/regex/input.js new file mode 100644 index 0000000..90bc366 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex/input.js @@ -0,0 +1,5 @@ +import { MyModule } from "my-library"; +import { App } from "my-library/components"; +import { Header, Footer } from "my-library/components/App"; + +console.log(MyModule, App, Header, Footer); diff --git a/packages/transform-imports/tests/fixtures/regex/output.js b/packages/transform-imports/tests/fixtures/regex/output.js new file mode 100644 index 0000000..582b911 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/regex/output.js @@ -0,0 +1,7 @@ +import MyModule from "my-library/MyModule"; +import App from "my-library/components/App"; +import Header from "my-library/components/App/Header"; +import Footer from "my-library/components/App/Footer"; +//#region virtual:entry.ts +console.log(MyModule, App, Header, Footer); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/side-effect-imports/config.json b/packages/transform-imports/tests/fixtures/side-effect-imports/config.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/side-effect-imports/config.json @@ -0,0 +1,2 @@ +{ +} diff --git a/packages/transform-imports/tests/fixtures/side-effect-imports/input.js b/packages/transform-imports/tests/fixtures/side-effect-imports/input.js new file mode 100644 index 0000000..35730f7 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/side-effect-imports/input.js @@ -0,0 +1,2 @@ +import './upload/upload.ts'; +import './scripts/scripts.model.js'; \ No newline at end of file diff --git a/packages/transform-imports/tests/fixtures/side-effect-imports/output.js b/packages/transform-imports/tests/fixtures/side-effect-imports/output.js new file mode 100644 index 0000000..ee12862 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/side-effect-imports/output.js @@ -0,0 +1,2 @@ +import "./upload/upload.ts"; +import "./scripts/scripts.model.js"; diff --git a/packages/transform-imports/tests/fixtures/simple-export/config.json b/packages/transform-imports/tests/fixtures/simple-export/config.json new file mode 100644 index 0000000..b1128bf --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple-export/config.json @@ -0,0 +1,13 @@ +{ + "react-bootstrap": { + "transform": "react-bootstrap/lib/{{member}}" + }, + "my-library-2": { + "transform": "my-library-2/{{ camelCase member }}", + "skipDefaultConversion": true + }, + "my-library-3": { + "transform": "my-library-3/{{ kebabCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/simple-export/input.js b/packages/transform-imports/tests/fixtures/simple-export/input.js new file mode 100644 index 0000000..278480b --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple-export/input.js @@ -0,0 +1,3 @@ +export { Grid, Row, Col as Col1 } from "react-bootstrap"; +export { MyModule, Widget } from "my-library-2"; +export { AnotherModule } from "my-library-3"; diff --git a/packages/transform-imports/tests/fixtures/simple-export/output.js b/packages/transform-imports/tests/fixtures/simple-export/output.js new file mode 100644 index 0000000..a88346f --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple-export/output.js @@ -0,0 +1,7 @@ +import Grid from "react-bootstrap/lib/Grid"; +import Row from "react-bootstrap/lib/Row"; +import Col1 from "react-bootstrap/lib/Col"; +import { MyModule } from "my-library-2/myModule"; +import { Widget } from "my-library-2/widget"; +import { AnotherModule } from "my-library-3/another-module"; +export { AnotherModule, Col1, Grid, MyModule, Row, Widget }; diff --git a/packages/transform-imports/tests/fixtures/simple/config.json b/packages/transform-imports/tests/fixtures/simple/config.json new file mode 100644 index 0000000..b1128bf --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple/config.json @@ -0,0 +1,13 @@ +{ + "react-bootstrap": { + "transform": "react-bootstrap/lib/{{member}}" + }, + "my-library-2": { + "transform": "my-library-2/{{ camelCase member }}", + "skipDefaultConversion": true + }, + "my-library-3": { + "transform": "my-library-3/{{ kebabCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/simple/input.js b/packages/transform-imports/tests/fixtures/simple/input.js new file mode 100644 index 0000000..f4ed62c --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple/input.js @@ -0,0 +1,5 @@ +import { Grid, Row, Col as Col1 } from "react-bootstrap"; +import { MyModule, Widget } from "my-library-2"; +import { AnotherModule } from "my-library-3"; + +console.log(Grid, Row, Col1, MyModule, Widget, AnotherModule); diff --git a/packages/transform-imports/tests/fixtures/simple/output.js b/packages/transform-imports/tests/fixtures/simple/output.js new file mode 100644 index 0000000..75960af --- /dev/null +++ b/packages/transform-imports/tests/fixtures/simple/output.js @@ -0,0 +1,9 @@ +import Grid from "react-bootstrap/lib/Grid"; +import Row from "react-bootstrap/lib/Row"; +import Col1 from "react-bootstrap/lib/Col"; +import { MyModule } from "my-library-2/myModule"; +import { Widget } from "my-library-2/widget"; +import { AnotherModule } from "my-library-3/another-module"; +//#region virtual:entry.ts +console.log(Grid, Row, Col1, MyModule, Widget, AnotherModule); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/skip-default-conversion/config.json b/packages/transform-imports/tests/fixtures/skip-default-conversion/config.json new file mode 100644 index 0000000..53523b9 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/skip-default-conversion/config.json @@ -0,0 +1,6 @@ +{ + "my-library-2": { + "transform": "my-library-2/{{ camelCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/skip-default-conversion/input.js b/packages/transform-imports/tests/fixtures/skip-default-conversion/input.js new file mode 100644 index 0000000..35433f6 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/skip-default-conversion/input.js @@ -0,0 +1,4 @@ +// Test skipDefaultConversion: keeps named imports instead of converting to default +import { MyModule, Widget } from "my-library-2"; + +console.log(MyModule, Widget); diff --git a/packages/transform-imports/tests/fixtures/skip-default-conversion/output.js b/packages/transform-imports/tests/fixtures/skip-default-conversion/output.js new file mode 100644 index 0000000..a790773 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/skip-default-conversion/output.js @@ -0,0 +1,5 @@ +import { MyModule } from "my-library-2/myModule"; +import { Widget } from "my-library-2/widget"; +//#region virtual:entry.ts +console.log(MyModule, Widget); +//#endregion diff --git a/packages/transform-imports/tests/fixtures/snake-case/config.json b/packages/transform-imports/tests/fixtures/snake-case/config.json new file mode 100644 index 0000000..5044d92 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/snake-case/config.json @@ -0,0 +1,6 @@ +{ + "test-snake-case": { + "transform": "test-snake-case/{{ snakeCase member }}", + "skipDefaultConversion": true + } +} diff --git a/packages/transform-imports/tests/fixtures/snake-case/input.js b/packages/transform-imports/tests/fixtures/snake-case/input.js new file mode 100644 index 0000000..3208644 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/snake-case/input.js @@ -0,0 +1,4 @@ +// Test snakeCase transformation +import { MyModule, SomeWidget } from "test-snake-case"; + +console.log(MyModule, SomeWidget); diff --git a/packages/transform-imports/tests/fixtures/snake-case/output.js b/packages/transform-imports/tests/fixtures/snake-case/output.js new file mode 100644 index 0000000..1d93460 --- /dev/null +++ b/packages/transform-imports/tests/fixtures/snake-case/output.js @@ -0,0 +1,5 @@ +import { MyModule } from "test-snake-case/my_module"; +import { SomeWidget } from "test-snake-case/some_widget"; +//#region virtual:entry.ts +console.log(MyModule, SomeWidget); +//#endregion diff --git a/packages/transform-imports/tests/transform.test.ts b/packages/transform-imports/tests/transform.test.ts new file mode 100644 index 0000000..4388b2c --- /dev/null +++ b/packages/transform-imports/tests/transform.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { rolldown } from 'rolldown' +import { transformImportsPlugin, type PluginConfig } from '../src/index.ts' +import { globSync } from 'tinyglobby' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +describe('transform-imports', () => { + it('throws on namespace import when preventFullImport is true', async () => { + const input = `import * as Bootstrap from 'react-bootstrap'\nconsole.log(Bootstrap)` + const config = { + 'react-bootstrap': { + transform: 'react-bootstrap/lib/{{member}}', + preventFullImport: true, + }, + } + + await expect(transform(input, config)).rejects.toThrow('preventFullImport') + }) + + it('throws on side-effect import when preventFullImport is true', async () => { + const input = `import 'react-bootstrap'` + const config = { + 'react-bootstrap': { + transform: 'react-bootstrap/lib/{{member}}', + preventFullImport: true, + }, + } + + await expect(transform(input, config)).rejects.toThrow('preventFullImport') + }) +}) + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const fixturePaths = globSync('*/input.js', { cwd: fixturesDir }) + +describe('fixtures', () => { + for (const inputPath of fixturePaths) { + const fixtureName = dirname(inputPath) + const input = readFileSync(join(fixturesDir, inputPath), 'utf-8') + const configPath = join(fixturesDir, fixtureName, 'config.json') + const config = JSON.parse(readFileSync(configPath, 'utf-8')) + + it(fixtureName, async () => { + const result = await transform(input, config) + await expect(result).toMatchFileSnapshot(join(fixturesDir, fixtureName, 'output.js')) + }) + } +}) + +async function transform(code: string, modules: PluginConfig): Promise { + const build = await rolldown({ + input: 'virtual:entry.ts', + plugins: [ + { + name: 'virtual', + resolveId(id) { + if (id === 'virtual:entry.ts') return id + // Mark transformed imports as external + return { id, external: true } + }, + load(id) { + if (id === 'virtual:entry.ts') return code + }, + }, + transformImportsPlugin({ modules }), + ], + }) + + const { output } = await build.generate({ format: 'esm' }) + return stripRolldownRuntime(output[0].code) +} + +function stripRolldownRuntime(code: string): string { + return code.replace( + /\/\/#region \\0rolldown\/runtime\.js[\s\S]*?\/\/#endregion\n*/g, + '// [rolldown runtime elided]\n', + ) +} diff --git a/packages/transform-imports/tsdown.config.ts b/packages/transform-imports/tsdown.config.ts new file mode 100644 index 0000000..a3a2720 --- /dev/null +++ b/packages/transform-imports/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: './src/index.ts', + dts: { + tsconfig: '../../tsconfig.common.json', + tsgo: true, + }, +}) diff --git a/packages/transform-imports/vitest.config.ts b/packages/transform-imports/vitest.config.ts new file mode 100644 index 0000000..e2fabd7 --- /dev/null +++ b/packages/transform-imports/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'transform-imports', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29afc40..117916c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -423,6 +423,22 @@ importers: specifier: ^1.0.0-rc.13 version: 1.0.0-rc.13 + packages/transform-imports: + dependencies: + rolldown-string: + specifier: ^0.3.0 + version: 0.3.0(rolldown@1.0.0-rc.13) + vite: + specifier: ^8.0.0 + version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.3) + devDependencies: + rolldown: + specifier: ^1.0.0-rc.13 + version: 1.0.0-rc.13 + tinyglobby: + specifier: ^0.2.15 + version: 0.2.15 + scripts: devDependencies: '@vitejs/release-scripts': diff --git a/scripts/release.ts b/scripts/release.ts index 0f85ee9..e58a74c 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -12,6 +12,7 @@ await release({ 'plugin-emotion', 'plugin-jsx-remove-attributes', 'plugin-styled-jsx', + 'plugin-transform-imports', 'oxc-unshadowed-visitor', ], toTag: (pkg, version) => `${pkg}@${version}`, From 4cd44366037cd0ba78594bdfb752854690049076 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:18:26 +0900 Subject: [PATCH 2/4] feat(transform-imports): add benchmark --- packages/transform-imports/README.md | 23 +++ .../transform-imports/benchmark/.gitignore | 9 + .../bench/transform-imports.bench.ts | 52 +++++ .../benchmark/configs/babel.ts | 41 ++++ .../benchmark/configs/custom.ts | 20 ++ .../benchmark/configs/swc.ts | 44 ++++ .../transform-imports/benchmark/package.json | 25 +++ .../benchmark/scripts/generate-app.ts | 194 ++++++++++++++++++ .../benchmark/vitest.config.ts | 7 + pnpm-lock.yaml | 78 +++++++ 10 files changed, 493 insertions(+) create mode 100644 packages/transform-imports/benchmark/.gitignore create mode 100644 packages/transform-imports/benchmark/bench/transform-imports.bench.ts create mode 100644 packages/transform-imports/benchmark/configs/babel.ts create mode 100644 packages/transform-imports/benchmark/configs/custom.ts create mode 100644 packages/transform-imports/benchmark/configs/swc.ts create mode 100644 packages/transform-imports/benchmark/package.json create mode 100644 packages/transform-imports/benchmark/scripts/generate-app.ts create mode 100644 packages/transform-imports/benchmark/vitest.config.ts diff --git a/packages/transform-imports/README.md b/packages/transform-imports/README.md index 2325e28..dad4f13 100644 --- a/packages/transform-imports/README.md +++ b/packages/transform-imports/README.md @@ -194,6 +194,29 @@ const mod = await import('lodash') // source is rewritten based on the transform template ``` +## Benchmark + +Results of the benchmark that can be run by `pnpm bench` in `./benchmark` directory: + +``` + name hz min max mean p75 p99 p995 p999 rme samples +· @rolldown/plugin-transform-imports 14.9081 65.9831 69.8849 67.0775 67.5907 69.8849 69.8849 69.8849 ±1.29% 10 +· babel-plugin-transform-imports 5.1254 184.90 246.64 195.11 191.14 246.64 246.64 246.64 ±6.84% 10 +· @swc/plugin-transform-imports 10.9555 88.5649 98.1298 91.2780 92.9843 98.1298 98.1298 98.1298 ±2.50% 10 + +@rolldown/plugin-transform-imports - bench/transform-imports.bench.ts > Transform Imports Benchmark + 1.36x faster than @swc/plugin-transform-imports + 2.91x faster than babel-plugin-transform-imports +``` + +The benchmark was ran on the following environment: + +``` +OS: macOS Tahoe 26.3 +CPU: Apple M4 +Memory: LPDDR5X-7500 32GB +``` + ## License MIT diff --git a/packages/transform-imports/benchmark/.gitignore b/packages/transform-imports/benchmark/.gitignore new file mode 100644 index 0000000..d2b9029 --- /dev/null +++ b/packages/transform-imports/benchmark/.gitignore @@ -0,0 +1,9 @@ +# Build outputs +dist/ + +# Generated modules (regenerated with pnpm generate) +shared-app/src/index.js +shared-app/src/modules/ + +# SWC plugin cache +.swc/ diff --git a/packages/transform-imports/benchmark/bench/transform-imports.bench.ts b/packages/transform-imports/benchmark/bench/transform-imports.bench.ts new file mode 100644 index 0000000..352d5e1 --- /dev/null +++ b/packages/transform-imports/benchmark/bench/transform-imports.bench.ts @@ -0,0 +1,52 @@ +import { bench, describe } from 'vitest' +import { execSync } from 'node:child_process' +import { existsSync, rmSync } from 'node:fs' +import { resolve } from 'node:path' + +const baseDir = resolve(import.meta.dirname, '..') +const distBase = resolve(baseDir, 'dist') +const modulesDir = resolve(baseDir, 'shared-app/src/modules') + +if (!existsSync(modulesDir)) { + execSync('pnpm generate', { cwd: baseDir, stdio: 'inherit' }) +} + +function cleanDist(name: string) { + const dir = resolve(distBase, name) + if (existsSync(dir)) { + rmSync(dir, { recursive: true }) + } +} + +function runBuild(name: string) { + execSync(`rolldown -c configs/${name}.ts`, { + cwd: baseDir, + stdio: 'pipe', + }) +} + +describe('Transform Imports Benchmark', () => { + bench( + '@rolldown/plugin-transform-imports', + () => { + runBuild('custom') + }, + { teardown: () => cleanDist('custom') }, + ) + + bench( + 'babel-plugin-transform-imports', + () => { + runBuild('babel') + }, + { teardown: () => cleanDist('babel') }, + ) + + bench( + '@swc/plugin-transform-imports', + () => { + runBuild('swc') + }, + { teardown: () => cleanDist('swc') }, + ) +}) diff --git a/packages/transform-imports/benchmark/configs/babel.ts b/packages/transform-imports/benchmark/configs/babel.ts new file mode 100644 index 0000000..3ac3272 --- /dev/null +++ b/packages/transform-imports/benchmark/configs/babel.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'rolldown' +import { resolve } from 'node:path' +import babel, { defineRolldownBabelPreset } from '@rolldown/plugin-babel' + +// babel-plugin-transform-imports uses ${member} syntax instead of {{member}} +// and camelCase/kebabCase are provided as function options, not template helpers. +// The Babel plugin supports a `transform` function in JS config. +const transformImportsPreset = defineRolldownBabelPreset({ + preset: () => ({ + plugins: [ + [ + 'babel-plugin-transform-imports', + { + 'ui-components': { transform: 'ui-components/lib/${member}', preventFullImport: false }, + 'utils-lib': { transform: 'utils-lib/${member}', preventFullImport: false }, + 'icons-pack': { transform: 'icons-pack/icons/${member}', preventFullImport: false }, + 'data-helpers': { transform: 'data-helpers/${member}', preventFullImport: false }, + 'form-controls': { transform: 'form-controls/lib/${member}', preventFullImport: false }, + }, + ], + ], + }), + rolldown: { + filter: { + id: { include: /\.[jt]sx?$/, exclude: /node_modules/ }, + }, + }, +}) + +export default defineConfig({ + input: resolve(import.meta.dirname, '../shared-app/src/index.js'), + external: [/^ui-components/, /^utils-lib/, /^icons-pack/, /^data-helpers/, /^form-controls/], + output: { + dir: resolve(import.meta.dirname, '../dist/babel'), + }, + plugins: [ + babel({ + presets: [transformImportsPreset], + }), + ], +}) diff --git a/packages/transform-imports/benchmark/configs/custom.ts b/packages/transform-imports/benchmark/configs/custom.ts new file mode 100644 index 0000000..2c03471 --- /dev/null +++ b/packages/transform-imports/benchmark/configs/custom.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'rolldown' +import { resolve } from 'node:path' +import transformImports from '@rolldown/plugin-transform-imports' + +const modules = { + 'ui-components': { transform: 'ui-components/lib/{{kebabCase member}}' }, + 'utils-lib': { transform: 'utils-lib/{{camelCase member}}' }, + 'icons-pack': { transform: 'icons-pack/icons/{{kebabCase member}}' }, + 'data-helpers': { transform: 'data-helpers/{{member}}' }, + 'form-controls': { transform: 'form-controls/lib/{{kebabCase member}}' }, +} + +export default defineConfig({ + input: resolve(import.meta.dirname, '../shared-app/src/index.js'), + external: [/^ui-components/, /^utils-lib/, /^icons-pack/, /^data-helpers/, /^form-controls/], + output: { + dir: resolve(import.meta.dirname, '../dist/custom'), + }, + plugins: [transformImports({ modules })], +}) diff --git a/packages/transform-imports/benchmark/configs/swc.ts b/packages/transform-imports/benchmark/configs/swc.ts new file mode 100644 index 0000000..2c7d527 --- /dev/null +++ b/packages/transform-imports/benchmark/configs/swc.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'rolldown' +import { resolve } from 'node:path' +import { withFilter } from 'rolldown/filter' +import swc from '@rollup/plugin-swc' + +export default defineConfig({ + input: resolve(import.meta.dirname, '../shared-app/src/index.js'), + external: [/^ui-components/, /^utils-lib/, /^icons-pack/, /^data-helpers/, /^form-controls/], + output: { + dir: resolve(import.meta.dirname, '../dist/swc'), + }, + plugins: [ + withFilter( + swc({ + swc: { + jsc: { + parser: { + syntax: 'ecmascript', + }, + experimental: { + plugins: [ + [ + '@swc/plugin-transform-imports', + { + 'ui-components': { transform: 'ui-components/lib/{{ kebabCase member }}' }, + 'utils-lib': { transform: 'utils-lib/{{ camelCase member }}' }, + 'icons-pack': { transform: 'icons-pack/icons/{{ kebabCase member }}' }, + 'data-helpers': { transform: 'data-helpers/{{ member }}' }, + 'form-controls': { transform: 'form-controls/lib/{{ kebabCase member }}' }, + }, + ], + ], + }, + }, + }, + }), + { + transform: { + id: { include: /\.[jt]sx?$/, exclude: /node_modules/ }, + }, + }, + ), + ], +}) diff --git a/packages/transform-imports/benchmark/package.json b/packages/transform-imports/benchmark/package.json new file mode 100644 index 0000000..7ec3fc5 --- /dev/null +++ b/packages/transform-imports/benchmark/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rolldown/benchmark-transform-imports", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "generate": "oxnode scripts/generate-app.ts", + "bench": "vitest bench --run", + "build:custom": "rolldown -c configs/custom.ts", + "build:babel": "rolldown -c configs/babel.ts", + "build:swc": "rolldown -c configs/swc.ts" + }, + "devDependencies": { + "@oxc-node/cli": "^0.1.0", + "@rolldown/benchmark-utils": "workspace:*", + "@rolldown/plugin-babel": "file:../../babel", + "@rolldown/plugin-transform-imports": "workspace:*", + "@rollup/plugin-swc": "^0.4.0", + "@swc/core": "^1.15.24", + "@swc/plugin-transform-imports": "^12.8.0", + "@types/node": "^24.12.2", + "babel-plugin-transform-imports": "^2.0.0", + "rolldown": "^1.0.0-rc.13" + } +} diff --git a/packages/transform-imports/benchmark/scripts/generate-app.ts b/packages/transform-imports/benchmark/scripts/generate-app.ts new file mode 100644 index 0000000..59c8d51 --- /dev/null +++ b/packages/transform-imports/benchmark/scripts/generate-app.ts @@ -0,0 +1,194 @@ +/** + * App generator for transform-imports benchmark. + * Generates ~100 JS files with varied import patterns. + * Uses seeded random (seed=42) for deterministic generation. + */ + +import { writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { SeededRandom } from '@rolldown/benchmark-utils/seeded-random' + +const rng = new SeededRandom(42) + +// Mock libraries whose imports will be transformed +const LIBRARIES = ['ui-components', 'utils-lib', 'icons-pack', 'data-helpers', 'form-controls'] + +// Members that can be imported from each library +const MEMBERS: Record = { + 'ui-components': [ + 'Button', + 'Card', + 'Modal', + 'Tooltip', + 'Dropdown', + 'Avatar', + 'Badge', + 'Breadcrumb', + 'Carousel', + 'Collapse', + 'DatePicker', + 'Divider', + 'Drawer', + 'Input', + 'Menu', + 'Pagination', + 'Popover', + 'Progress', + 'Radio', + 'Select', + 'Slider', + 'Switch', + 'Table', + 'Tabs', + 'Tag', + 'TimePicker', + 'Transfer', + 'TreeSelect', + 'Upload', + ], + 'utils-lib': [ + 'debounce', + 'throttle', + 'cloneDeep', + 'isEmpty', + 'isEqual', + 'merge', + 'groupBy', + 'sortBy', + 'uniqBy', + 'flattenDeep', + 'mapValues', + 'pickBy', + 'omitBy', + 'capitalize', + 'camelCase', + 'kebabCase', + ], + 'icons-pack': [ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Check', + 'Close', + 'Edit', + 'Delete', + 'Search', + 'Filter', + 'Settings', + 'Home', + 'User', + 'Bell', + 'Star', + 'Heart', + 'Mail', + 'Lock', + 'Unlock', + 'Calendar', + ], + 'data-helpers': [ + 'fetchData', + 'postData', + 'putData', + 'deleteData', + 'parseResponse', + 'formatError', + 'buildQuery', + 'encodeParams', + 'decodeParams', + 'validateSchema', + 'transformPayload', + 'serializeForm', + ], + 'form-controls': [ + 'TextField', + 'NumberField', + 'CheckboxField', + 'RadioField', + 'SelectField', + 'DateField', + 'FileField', + 'TextAreaField', + 'PasswordField', + 'SearchField', + 'ColorField', + 'RangeField', + ], +} + +type PatternType = 'named' | 'mixed' | 'reexport' | 'dynamic' | 'sideEffect' +const PATTERN_TYPES: PatternType[] = [ + 'named', + 'named', + 'named', + 'mixed', + 'reexport', + 'dynamic', + 'sideEffect', +] + +function generateNamedImports(lib: string, members: string[]): string { + const count = rng.nextInt(4) + 1 + const picked = rng.pickN(members, Math.min(count, members.length)) + const usage = picked.map((m) => `console.log(${m})`).join('\n') + return `import { ${picked.join(', ')} } from '${lib}'\n${usage}\n` +} + +function generateMixedImports(lib: string, members: string[]): string { + const picked = rng.pickN(members, Math.min(2, members.length)) + const usage = picked.map((m) => `console.log(${m})`).join('\n') + return `import Lib, { ${picked.join(', ')} } from '${lib}'\nconsole.log(Lib)\n${usage}\n` +} + +function generateReexport(lib: string, members: string[]): string { + const picked = rng.pickN(members, Math.min(3, members.length)) + return `export { ${picked.join(', ')} } from '${lib}'\n` +} + +function generateDynamicImport(lib: string): string { + return `const mod = await import('${lib}')\nconsole.log(mod)\n` +} + +function generateSideEffectImport(lib: string): string { + return `import '${lib}'\n` +} + +const GENERATORS: Record string> = { + named: generateNamedImports, + mixed: generateMixedImports, + reexport: generateReexport, + dynamic: generateDynamicImport, + sideEffect: generateSideEffectImport, +} + +function main() { + const appDir = join(import.meta.dirname, '../shared-app/src') + if (existsSync(appDir)) rmSync(appDir, { recursive: true }) + mkdirSync(join(appDir, 'modules'), { recursive: true }) + + const TOTAL = 100 + const files: string[] = [] + + for (let i = 0; i < TOTAL; i++) { + const lib = rng.pick(LIBRARIES) + const members = MEMBERS[lib] + const patternType = rng.pick(PATTERN_TYPES) + const content = GENERATORS[patternType](lib, members) + const filename = `module${i + 1}.js` + writeFileSync(join(appDir, 'modules', filename), content) + files.push(filename) + } + + // Generate entry file that imports all modules + const entry = files + .map((f, i) => `import './modules/${f}'\nconsole.log('module ${i + 1}')`) + .join('\n') + writeFileSync(join(appDir, 'index.js'), entry + '\n') + + console.log(`Generated ${TOTAL} modules in ${appDir}/modules/`) + for (const type of PATTERN_TYPES) { + console.log(` Pattern distribution includes: ${type}`) + } +} + +main() diff --git a/packages/transform-imports/benchmark/vitest.config.ts b/packages/transform-imports/benchmark/vitest.config.ts new file mode 100644 index 0000000..bd4ac39 --- /dev/null +++ b/packages/transform-imports/benchmark/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'benchmark-transform-imports', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 117916c..ca6b4e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,6 +439,39 @@ importers: specifier: ^0.2.15 version: 0.2.15 + packages/transform-imports/benchmark: + devDependencies: + '@oxc-node/cli': + specifier: ^0.1.0 + version: 0.1.0 + '@rolldown/benchmark-utils': + specifier: workspace:* + version: link:../../../internal-packages/benchmark-utils + '@rolldown/plugin-babel': + specifier: file:../../babel + version: link:../../babel + '@rolldown/plugin-transform-imports': + specifier: workspace:* + version: link:.. + '@rollup/plugin-swc': + specifier: ^0.4.0 + version: 0.4.0(@swc/core@1.15.24) + '@swc/core': + specifier: ^1.15.24 + version: 1.15.24 + '@swc/plugin-transform-imports': + specifier: ^12.8.0 + version: 12.8.0 + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + babel-plugin-transform-imports: + specifier: ^2.0.0 + version: 2.0.0 + rolldown: + specifier: ^1.0.0-rc.13 + version: 1.0.0-rc.13 + scripts: devDependencies: '@vitejs/release-scripts': @@ -1782,6 +1815,9 @@ packages: '@swc/plugin-styled-jsx@13.8.0': resolution: {integrity: sha512-ptbmkoEgdiF25SOvQyR0icd5ARj7kuaJmQAyDZl/xPfGIKt9b7LoXef1ZoY1i7osXCJ/m9cUkR5Lrf57E8cpUA==} + '@swc/plugin-transform-imports@12.8.0': + resolution: {integrity: sha512-5/iZPNxrQlBpKRWLmLjk+YUBXJ5uob5CYUeWEV5Rre/HqPNtsdKS7lv8cz+0b6xFZ9iggxQ+GOAmAReYxal73A==} + '@swc/types@0.1.26': resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} @@ -1937,6 +1973,9 @@ packages: babel-plugin-react-remove-properties@0.3.1: resolution: {integrity: sha512-JQ0oEgC4P6TZxSqEaoPZdbgE2L38Kz7RgTgGcOUUR24Z19OiGhnhdXkXkwwsYyx6LOr/bMY5ljDADhxDr/JQug==} + babel-plugin-transform-imports@2.0.0: + resolution: {integrity: sha512-65ewumYJ85QiXdcB/jmiU0y0jg6eL6CdnDqQAqQ8JMOKh1E52VPG3NJzbVKWcgovUR5GBH8IWpCXQ7I8Q3wjgw==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -2178,6 +2217,18 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-extglob@1.0.0: + resolution: {integrity: sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==} + engines: {node: '>=0.10.0'} + + is-glob@2.0.1: + resolution: {integrity: sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==} + engines: {node: '>=0.10.0'} + + is-invalid-path@0.1.0: + resolution: {integrity: sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==} + engines: {node: '>=0.10.0'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -2198,6 +2249,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-valid-path@0.1.1: + resolution: {integrity: sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==} + engines: {node: '>=0.10.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3833,6 +3888,10 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@swc/plugin-transform-imports@12.8.0': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types@0.1.26': dependencies: '@swc/counter': 0.1.3 @@ -3991,6 +4050,11 @@ snapshots: babel-plugin-react-remove-properties@0.3.1: {} + babel-plugin-transform-imports@2.0.0: + dependencies: + '@babel/types': 7.29.0 + is-valid-path: 0.1.1 + baseline-browser-mapping@2.10.0: {} birpc@4.0.0: {} @@ -4239,6 +4303,16 @@ snapshots: dependencies: hasown: 2.0.2 + is-extglob@1.0.0: {} + + is-glob@2.0.1: + dependencies: + is-extglob: 1.0.0 + + is-invalid-path@0.1.0: + dependencies: + is-glob: 2.0.1 + is-obj@2.0.0: {} is-plain-obj@4.1.0: {} @@ -4249,6 +4323,10 @@ snapshots: is-unicode-supported@2.1.0: {} + is-valid-path@0.1.1: + dependencies: + is-invalid-path: 0.1.0 + isexe@2.0.0: {} js-tokens@10.0.0: {} From 04664602286f8b106a4dc5ed18526a297f3e5350 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:27:55 +0900 Subject: [PATCH 3/4] feat(transform-imports): add e2e example --- examples/transform-imports/index.html | 12 +++++++++++ examples/transform-imports/package.json | 14 +++++++++++++ examples/transform-imports/src/main.ts | 19 ++++++++++++++++++ .../transform-imports/src/mock-lib/button.ts | 6 ++++++ .../transform-imports/src/mock-lib/card.ts | 6 ++++++ .../transform-imports/src/mock-lib/index.ts | 5 +++++ .../transform-imports/src/mock-lib/input.ts | 6 ++++++ .../transform-imports/src/mock-lib/modal.ts | 6 ++++++ .../transform-imports/src/mock-lib/select.ts | 8 ++++++++ .../transform-imports.test.ts | 20 +++++++++++++++++++ examples/transform-imports/vite.config.ts | 20 +++++++++++++++++++ pnpm-lock.yaml | 9 +++++++++ 12 files changed, 131 insertions(+) create mode 100644 examples/transform-imports/index.html create mode 100644 examples/transform-imports/package.json create mode 100644 examples/transform-imports/src/main.ts create mode 100644 examples/transform-imports/src/mock-lib/button.ts create mode 100644 examples/transform-imports/src/mock-lib/card.ts create mode 100644 examples/transform-imports/src/mock-lib/index.ts create mode 100644 examples/transform-imports/src/mock-lib/input.ts create mode 100644 examples/transform-imports/src/mock-lib/modal.ts create mode 100644 examples/transform-imports/src/mock-lib/select.ts create mode 100644 examples/transform-imports/transform-imports.test.ts create mode 100644 examples/transform-imports/vite.config.ts diff --git a/examples/transform-imports/index.html b/examples/transform-imports/index.html new file mode 100644 index 0000000..0b5a3c1 --- /dev/null +++ b/examples/transform-imports/index.html @@ -0,0 +1,12 @@ + + + + + + Transform Imports Example + + +
+ + + diff --git a/examples/transform-imports/package.json b/examples/transform-imports/package.json new file mode 100644 index 0000000..b48a118 --- /dev/null +++ b/examples/transform-imports/package.json @@ -0,0 +1,14 @@ +{ + "name": "@rolldown/example-transform-imports", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@rolldown/plugin-transform-imports": "workspace:*", + "vite": "^8.0.7" + } +} diff --git a/examples/transform-imports/src/main.ts b/examples/transform-imports/src/main.ts new file mode 100644 index 0000000..81a3328 --- /dev/null +++ b/examples/transform-imports/src/main.ts @@ -0,0 +1,19 @@ +// @ts-expect-error not typed +import { Button, Card, Modal } from 'mock-lib' + +const app = document.getElementById('app')! + +const heading = document.createElement('h1') +heading.textContent = 'Transform Imports Example' +heading.className = 'app-title' +app.appendChild(heading) + +const description = document.createElement('p') +description.textContent = + 'These components are imported via barrel import, transformed to individual imports by the plugin.' +description.className = 'app-description' +app.appendChild(description) + +app.appendChild(Button()) +app.appendChild(Card()) +app.appendChild(Modal()) diff --git a/examples/transform-imports/src/mock-lib/button.ts b/examples/transform-imports/src/mock-lib/button.ts new file mode 100644 index 0000000..8c69cb3 --- /dev/null +++ b/examples/transform-imports/src/mock-lib/button.ts @@ -0,0 +1,6 @@ +export default function Button() { + const el = document.createElement('button') + el.textContent = 'Button' + el.className = 'mock-button' + return el +} diff --git a/examples/transform-imports/src/mock-lib/card.ts b/examples/transform-imports/src/mock-lib/card.ts new file mode 100644 index 0000000..60b123b --- /dev/null +++ b/examples/transform-imports/src/mock-lib/card.ts @@ -0,0 +1,6 @@ +export default function Card() { + const el = document.createElement('div') + el.textContent = 'Card' + el.className = 'mock-card' + return el +} diff --git a/examples/transform-imports/src/mock-lib/index.ts b/examples/transform-imports/src/mock-lib/index.ts new file mode 100644 index 0000000..909ddca --- /dev/null +++ b/examples/transform-imports/src/mock-lib/index.ts @@ -0,0 +1,5 @@ +export { default as Button } from './button.js' +export { default as Input } from './input.js' +export { default as Select } from './select.js' +export { default as Modal } from './modal.js' +export { default as Card } from './card.js' diff --git a/examples/transform-imports/src/mock-lib/input.ts b/examples/transform-imports/src/mock-lib/input.ts new file mode 100644 index 0000000..14169ef --- /dev/null +++ b/examples/transform-imports/src/mock-lib/input.ts @@ -0,0 +1,6 @@ +export default function Input() { + const el = document.createElement('input') + el.placeholder = 'Input' + el.className = 'mock-input' + return el +} diff --git a/examples/transform-imports/src/mock-lib/modal.ts b/examples/transform-imports/src/mock-lib/modal.ts new file mode 100644 index 0000000..1d6d27e --- /dev/null +++ b/examples/transform-imports/src/mock-lib/modal.ts @@ -0,0 +1,6 @@ +export default function Modal() { + const el = document.createElement('div') + el.textContent = 'Modal' + el.className = 'mock-modal' + return el +} diff --git a/examples/transform-imports/src/mock-lib/select.ts b/examples/transform-imports/src/mock-lib/select.ts new file mode 100644 index 0000000..2fc101a --- /dev/null +++ b/examples/transform-imports/src/mock-lib/select.ts @@ -0,0 +1,8 @@ +export default function Select() { + const el = document.createElement('select') + el.className = 'mock-select' + const option = document.createElement('option') + option.textContent = 'Select' + el.appendChild(option) + return el +} diff --git a/examples/transform-imports/transform-imports.test.ts b/examples/transform-imports/transform-imports.test.ts new file mode 100644 index 0000000..a83c113 --- /dev/null +++ b/examples/transform-imports/transform-imports.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest' +import { page } from '~utils' + +test('should render button component', async () => { + const el = page.locator('.mock-button') + const text = await el.textContent() + expect(text).toBe('Button') +}) + +test('should render card component', async () => { + const el = page.locator('.mock-card') + const text = await el.textContent() + expect(text).toBe('Card') +}) + +test('should render modal component', async () => { + const el = page.locator('.mock-modal') + const text = await el.textContent() + expect(text).toBe('Modal') +}) diff --git a/examples/transform-imports/vite.config.ts b/examples/transform-imports/vite.config.ts new file mode 100644 index 0000000..7f1c871 --- /dev/null +++ b/examples/transform-imports/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import transformImports from '@rolldown/plugin-transform-imports' +import path from 'node:path' + +export default defineConfig({ + plugins: [ + transformImports({ + modules: { + 'mock-lib': { + transform: 'mock-lib/{{kebabCase member}}', + }, + }, + }), + ], + resolve: { + alias: { + 'mock-lib': path.resolve(import.meta.dirname, './src/mock-lib'), + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca6b4e1..33484d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,15 @@ importers: specifier: ^8.0.7 version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.3) + examples/transform-imports: + devDependencies: + '@rolldown/plugin-transform-imports': + specifier: workspace:* + version: link:../../packages/transform-imports + vite: + specifier: ^8.0.7 + version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.3) + internal-packages/benchmark-utils: devDependencies: '@oxc-node/core': From f790660263e8add76bb039c6efb9a50332824ff7 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:31:44 +0900 Subject: [PATCH 4/4] feat(swc-output-gen): add transform-imports plugin --- internal-packages/swc-output-gen/package.json | 1 + .../swc-output-gen/src/plugin-registry.ts | 11 +++++++++++ pnpm-lock.yaml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/internal-packages/swc-output-gen/package.json b/internal-packages/swc-output-gen/package.json index 04fe36b..fe3a6ea 100644 --- a/internal-packages/swc-output-gen/package.json +++ b/internal-packages/swc-output-gen/package.json @@ -13,6 +13,7 @@ "@swc/plugin-emotion": "^14.8.0", "@swc/plugin-react-remove-properties": "^12.8.0", "@swc/plugin-styled-jsx": "^13.8.0", + "@swc/plugin-transform-imports": "^12.8.0", "rolldown": "^1.0.0-rc.13", "tinyglobby": "^0.2.15" } diff --git a/internal-packages/swc-output-gen/src/plugin-registry.ts b/internal-packages/swc-output-gen/src/plugin-registry.ts index e9cc0f7..9bc0753 100644 --- a/internal-packages/swc-output-gen/src/plugin-registry.ts +++ b/internal-packages/swc-output-gen/src/plugin-registry.ts @@ -46,6 +46,17 @@ export const pluginRegistry: Record = { return [['@swc/plugin-styled-jsx', swcConfig]] }, }, + 'transform-imports': { + packages: ['@swc/plugin-transform-imports'], + mapOptions: (config) => [['@swc/plugin-transform-imports', config]], + shouldSkip: (config) => { + // SWC plugin only supports camelCase and kebabCase helpers + // Our ported plugin also supports snakeCase, lowerCase, upperCase + const unsupportedHelpers = ['snakeCase', 'lowerCase', 'upperCase'] + const configStr = JSON.stringify(config) + return unsupportedHelpers.some((helper) => configStr.includes(helper)) + }, + }, } /** Get list of all supported plugin names */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33484d4..e08c946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@swc/plugin-styled-jsx': specifier: ^13.8.0 version: 13.8.0 + '@swc/plugin-transform-imports': + specifier: ^12.8.0 + version: 12.8.0 rolldown: specifier: ^1.0.0-rc.13 version: 1.0.0-rc.13