diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7574f7..dfe24e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: vite-plugin-compiled-react: dependencies: + '@babel/core': + specifier: ^7.26.10 + version: 7.26.10 '@babel/types': specifier: ^7.27.0 version: 7.27.0 @@ -63,6 +66,9 @@ importers: '@compiled/react': specifier: ^0.18.3 version: 0.18.3(react@19.1.0) + '@types/babel__core': + specifier: 7.20.5 + version: 7.20.5 '@types/node': specifier: ^22.14.0 version: 22.14.0 diff --git a/vite-plugin-compiled-react/package.json b/vite-plugin-compiled-react/package.json index 8d47974..2f0fc5b 100644 --- a/vite-plugin-compiled-react/package.json +++ b/vite-plugin-compiled-react/package.json @@ -14,7 +14,8 @@ }, "scripts": { "build": "rimraf lib && tsc -b && cat src/types.d.ts >> lib/index.d.ts", - "dev": "tsc -w" + "dev": "tsc -w", + "test": "pnpm build && node --test test/*.test.mjs" }, "exports": { "import": "./lib/index.js" @@ -23,6 +24,7 @@ "lib" ], "dependencies": { + "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", "@compiled/babel-plugin": "^0.37.1", "@compiled/babel-plugin-strip-runtime": "^0.37.1", @@ -35,6 +37,7 @@ }, "devDependencies": { "@compiled/react": "^0.18.3", + "@types/babel__core": "7.20.5", "@types/node": "^22.14.0", "rimraf": "^6.0.1", "typescript": "^5.8.3", diff --git a/vite-plugin-compiled-react/src/index.ts b/vite-plugin-compiled-react/src/index.ts index 55510eb..362868b 100644 --- a/vite-plugin-compiled-react/src/index.ts +++ b/vite-plugin-compiled-react/src/index.ts @@ -1,10 +1,10 @@ import t from '@babel/types'; +import babel from '@babel/core'; import compiledPlugin from '@compiled/babel-plugin'; import compiledStripRuntimePlugin from '@compiled/babel-plugin-strip-runtime'; -import type { ReactBabelOptions } from '@vitejs/plugin-react'; import moduleResolverPlugin from 'babel-plugin-module-resolver'; import { createHash } from 'crypto'; -import { EnvironmentModuleNode, type Plugin } from 'vite'; +import { createFilter, EnvironmentModuleNode, type Plugin } from 'vite'; export type CompiledPluginOptions = { /** @@ -37,8 +37,10 @@ export type CompiledPluginOptions = { }; const virtualCssFiles = new Map(); +const defaultIncludeRE = /\.[tj]sx?$/; export const compiled = (options: CompiledPluginOptions = {}): Plugin => { + const filter = createFilter(defaultIncludeRE); const hash = (code: string) => { return createHash('md5').update(code).digest('hex').substring(2, 9); }; @@ -52,6 +54,8 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { let command = ''; let root: string; const moduleResolverPluginAlias = {}; + let plugins: babel.PluginItem[] = []; + return { name: 'vite-plugin-compiled-react', enforce: 'pre', @@ -82,6 +86,55 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { moduleResolverPluginAlias[find] = replacement; } } + + plugins = [ + { + visitor: { + Program(root) { + if (/extractAssets/.test(this.filename)) { + return; + } + root.unshiftContainer('body', importDeclaration); + }, + }, + }, + [moduleResolverPlugin, { root, alias: moduleResolverPluginAlias }], + [compiledPlugin, { importReact: false, ...baseOptions }], + ]; + + if ( + options.extract && + (options.extract === true || + (command === 'serve' && options.extract.serve) || + (command === 'build' && options.extract.build)) + ) { + plugins.push([ + compiledStripRuntimePlugin, + { compiledRequireExclude: true }, + ]); + + plugins.push({ + visitor: { + Program: { + exit(path, { file }) { + const styleRules = file.metadata.styleRules; + if (styleRules.length) { + const code = styleRules.join('\n'); + const fileId = hash(code) + '.css'; + virtualCssFiles.set(fileId, styleRules.join('\n')); + path.unshiftContainer( + 'body', + t.importDeclaration( + [], + t.stringLiteral(`${virtualCssFileName}:${fileId}`) + ) + ); + } + }, + }, + }, + }); + } }, resolveId(source, importer, options) { if (source.startsWith(virtualCssFileName)) { @@ -145,66 +198,43 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { `; } }, - api: { - reactBabel(babelConfig: ReactBabelOptions) { - babelConfig.plugins.push({ - visitor: { - Program(root) { - if ( - /node_modules/.test(this.filename) || - /extractAssets/.test(this.filename) - ) { - return; - } - if (/\.[jt]sx$/.test(this.filename)) { - root.unshiftContainer('body', importDeclaration); - } - }, - }, - }); + async transform(code, id) { + // Keep the same default boundary as @vitejs/plugin-react: dependencies are excluded, the + // query is stripped before filtering, and plain .js/.ts files are eligible as well. + if (id.includes('/node_modules/')) { + return; + } + const [filepath] = id.split('?'); + if (!filepath || !filter(filepath)) { + return; + } + if ( + !filepath.endsWith('x') && + !code.includes("'@compiled/react'") && + !code.includes('"@compiled/react"') + ) { + return; + } + const res = await babel.transformAsync(code, { + filename: id, + sourceFileName: filepath, + sourceMaps: true, + plugins, + // Parse only: TypeScript and JSX are left for Vite's own transform. Babel must still + // understand them, otherwise annotations and `interface` are syntax errors here. + parserOpts: { plugins: ['jsx', 'typescript'] }, + configFile: false, + babelrc: false, + }); - babelConfig.plugins.push([ - moduleResolverPlugin, - { root, alias: moduleResolverPluginAlias }, - ]); - babelConfig.plugins.push([ - compiledPlugin, - { importReact: false, ...baseOptions }, - ]); - if ( - options.extract && - (options.extract === true || - (command === 'serve' && options.extract.serve) || - (command === 'build' && options.extract.build)) - ) { - babelConfig.plugins.push([ - compiledStripRuntimePlugin, - { compiledRequireExclude: true }, - ]); - - babelConfig.plugins.push({ - visitor: { - Program: { - exit(path, { file }) { - const styleRules = file.metadata.styleRules; - if (styleRules.length) { - const code = styleRules.join('\n'); - const fileId = hash(code) + '.css'; - virtualCssFiles.set(fileId, styleRules.join('\n')); - path.unshiftContainer( - 'body', - t.importDeclaration( - [], - t.stringLiteral(`${virtualCssFileName}:${fileId}`) - ) - ); - } - }, - }, - }, - }); - } - }, + if (!res || !res.code) { + return; + } + + return { + code: res.code, + map: res.map, + }; }, }; }; diff --git a/vite-plugin-compiled-react/test/filter.test.mjs b/vite-plugin-compiled-react/test/filter.test.mjs new file mode 100644 index 0000000..73fe597 --- /dev/null +++ b/vite-plugin-compiled-react/test/filter.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; + +let compiled; + +before(async () => { + ({ compiled } = await import('../lib/index.js')); +}); + +describe('transform filter', () => { + it('transforms plain JavaScript files', async () => { + const result = await transform( + "import { css } from '@compiled/react'; export const value = css({ color: 'red' });", + '/project/src/style.js' + ); + + assertCompiled(result); + }); + + it('transforms plain TypeScript files', async () => { + const result = await transform( + "import { css } from '@compiled/react'; export const value: string = css({ color: 'red' });", + '/project/src/style.ts' + ); + + assertCompiled(result); + }); + + it('strips the query before filtering', async () => { + const result = await transform( + "export const Page = () =>
;", + '/project/src/style.tsx?direct' + ); + + assertCompiled(result); + assert.match(result.code, /className=\{ax/); + }); + + it('does not transform dependencies', async () => { + const result = await transform( + "// @flow\nopaque type Token = string; export const token: Token = 'dependency';", + '/project/node_modules/flow-dependency/index.jsx' + ); + + assert.equal(result, undefined); + }); + + it('does not transform plain JavaScript without a Compiled import', async () => { + const result = await transform( + "import { jsx } from 'react/jsx-runtime'; export const Page = () => jsx('div', {});", + '/project/generated/runtime.js' + ); + + assert.equal(result, undefined); + }); +}); + +async function transform(code, id) { + const plugin = compiled(); + plugin.configResolved({ root: '/project', resolve: { alias: [] } }); + return plugin.transform.call({}, code, id); +} + +function assertCompiled(result) { + assert.ok(result); + assert.match(result.code, /generated by @compiled\/babel-plugin/); +}