Detect and migrate static plugin usages (#14648)

This PR builds on top of the new [JS config to CSS
migration](https://github.com/tailwindlabs/tailwindcss/pull/14651) and
extends it to support migrating _static_ plugins.

What are _static_ plugins you might ask? Static plugins are plugins
where we can statically determine that these are coming from a different
file (so there is nothing inside the JS config that creates them). An
example for this is this config file:

```js
import typographyPlugin from '@tailwindcss/typography'
import { type Config } from 'tailwindcss'

export default {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  darkMode: 'selector',
  plugins: [typographyPlugin],
} satisfies Config
```

Here, the `plugins` array only has one element and it is a static import
from the `@tailwindcss/typography` module. In this PR we attempt to
parse the config file via Tree-sitter to extract the following
information from this file:

- What are the contents of the `plugins` array
- What are statically imported resources from the file

We then check if _all_ entries in the `plugins` array are either static
resources or _strings_ (something I saw working in some tests but I’m
not sure it still does). We migrate the JS config file to CSS if all
plugins are static and we can migrate them to CSS `@plugin` calls.

## Todo

This will need to be rebased after the updated tests in #14648
This commit is contained in:
Philipp Spiess 2024-10-14 17:45:36 +02:00 committed by GitHub
parent 4b19de3a45
commit 468cb5e99e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 529 additions and 65 deletions

View File

@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Migrate v3 PostCSS setups to v4 in some cases ([#14612](https://github.com/tailwindlabs/tailwindcss/pull/14612))
- _Upgrade (experimental)_: Automatically discover JavaScript config files ([#14597](https://github.com/tailwindlabs/tailwindcss/pull/14597))
- _Upgrade (experimental)_: Migrate legacy classes to the v4 alternative ([#14643](https://github.com/tailwindlabs/tailwindcss/pull/14643))
- _Upgrade (experimental)_: Migrate static JS configurations to CSS ([#14639](https://github.com/tailwindlabs/tailwindcss/pull/14639), [#14650](https://github.com/tailwindlabs/tailwindcss/pull/14650))
- _Upgrade (experimental)_: Migrate static JS configurations to CSS ([#14639](https://github.com/tailwindlabs/tailwindcss/pull/14639), [#14650](https://github.com/tailwindlabs/tailwindcss/pull/14650), [#14648](https://github.com/tailwindlabs/tailwindcss/pull/14648))
- _Upgrade (experimental)_: Migrate `@media screen(…)` when running codemods ([#14603](https://github.com/tailwindlabs/tailwindcss/pull/14603))
- _Upgrade (experimental)_: Inject `@config "…"` when a `tailwind.config.{js,ts,…}` is detected ([#14635](https://github.com/tailwindlabs/tailwindcss/pull/14635))
- _Upgrade (experimental)_: Migrate `aria-*`, `data-*`, and `supports-*` variants from arbitrary values to bare values ([#14644](https://github.com/tailwindlabs/tailwindcss/pull/14644))

View File

@ -102,6 +102,61 @@ test(
},
)
test(
'upgrades JS config files with plugins',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
import customPlugin from './custom-plugin'
export default {
plugins: [typography, customPlugin],
} satisfies Config
`,
'custom-plugin.js': ts`
export default function ({ addVariant }) {
addVariant('inverted', '@media (inverted-colors: inverted)')
addVariant('hocus', ['&:focus', '&:hover'])
}
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
--- src/input.css ---
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '../custom-plugin';
"
`)
expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
"
"
`)
},
)
test(
'does not upgrade JS config files with functions in the theme config',
{
@ -231,66 +286,6 @@ test(
},
)
test(
'does not upgrade JS config files with plugins',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
import customPlugin from './custom-plugin'
export default {
plugins: [typography, customPlugin],
} satisfies Config
`,
'custom-plugin.js': ts`
export default function ({ addVariant }) {
addVariant('inverted', '@media (inverted-colors: inverted)')
addVariant('hocus', ['&:focus', '&:hover'])
}
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
--- src/input.css ---
@import 'tailwindcss';
@config '../tailwind.config.ts';
"
`)
expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
"
--- tailwind.config.ts ---
import { type Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
import customPlugin from './custom-plugin'
export default {
plugins: [typography, customPlugin],
} satisfies Config
"
`)
},
)
test(
`does not upgrade JS config files with inline plugins`,
{

View File

@ -39,7 +39,9 @@
"postcss-selector-parser": "^6.1.2",
"prettier": "^3.3.3",
"string-byte-slice": "^3.0.0",
"tailwindcss": "workspace:^"
"tailwindcss": "workspace:^",
"tree-sitter": "^0.21.1",
"tree-sitter-typescript": "^0.23.0"
},
"devDependencies": {
"@types/node": "catalog:",

View File

@ -55,11 +55,21 @@ export function migrateConfig(
let absolute = path.resolve(source.base, source.pattern)
css += `@source '${relativeToStylesheet(sheet, absolute)}';\n`
}
if (jsConfigMigration.sources.length > 0) {
css = css + '\n'
}
for (let plugin of jsConfigMigration.plugins) {
let relative =
plugin.path[0] === '.'
? relativeToStylesheet(sheet, path.resolve(plugin.base, plugin.path))
: plugin.path
css += `@plugin '${relative}';\n`
}
if (jsConfigMigration.plugins.length > 0) {
css = css + '\n'
}
cssConfig.append(postcss.parse(css + jsConfigMigration.css))
}

View File

@ -12,6 +12,7 @@ import { deepMerge } from '../../tailwindcss/src/compat/config/deep-merge'
import { mergeThemeExtension } from '../../tailwindcss/src/compat/config/resolve-config'
import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import { findStaticPlugins } from './utils/extract-static-plugins'
import { info } from './utils/renderer'
const __filename = fileURLToPath(import.meta.url)
@ -21,6 +22,7 @@ export type JSConfigMigration =
// Could not convert the config file, need to inject it as-is in a @config directive
null | {
sources: { base: string; pattern: string }[]
plugins: { base: string; path: string }[]
css: string
}
@ -41,6 +43,7 @@ export async function migrateJsConfig(
}
let sources: { base: string; pattern: string }[] = []
let plugins: { base: string; path: string }[] = []
let cssConfigs: string[] = []
if ('darkMode' in unresolvedConfig) {
@ -56,8 +59,16 @@ export async function migrateJsConfig(
if (themeConfig) cssConfigs.push(themeConfig)
}
let simplePlugins = findStaticPlugins(source)
if (simplePlugins !== null) {
for (let plugin of simplePlugins) {
plugins.push({ base, path: plugin })
}
}
return {
sources,
plugins,
css: cssConfigs.join('\n'),
}
}
@ -168,7 +179,9 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
return ['string', 'number', 'boolean', 'undefined'].includes(typeof value)
}
if (!isSimpleValue(unresolvedConfig)) {
// Plugins are more complex, so we have a special heuristics for them.
let { plugins, ...remainder } = unresolvedConfig
if (!isSimpleValue(remainder)) {
return false
}
@ -186,7 +199,7 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
return false
}
if (unresolvedConfig.plugins && unresolvedConfig.plugins.length > 0) {
if (findStaticPlugins(source) === null) {
return false
}

View File

@ -0,0 +1,201 @@
import dedent from 'dedent'
import { describe, expect, test } from 'vitest'
import { extractStaticImportMap, findStaticPlugins } from './extract-static-plugins'
const js = dedent
describe('findStaticPlugins', () => {
test('parses all export styles', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
}
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
} as any
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
} satisfies any
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
expect(
findStaticPlugins(js`
const plugin1 = require('./plugin1')
module.exports = {
plugins: [plugin1, 'plugin2', require('./plugin3')]
} as any
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
expect(
findStaticPlugins(js`
const plugin1 = require('./plugin1')
module.exports = {
plugins: [plugin1, 'plugin2', require('./plugin3')]
} satisfies any
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
expect(
findStaticPlugins(js`
const plugin1 = require('./plugin1')
module.exports = {
plugins: [plugin1, 'plugin2', require('./plugin3')]
}
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
})
test('bails out on inline plugins', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
export default {
plugins: [plugin1, () => {} ]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
let plugin1 = () => {}
export default {
plugins: [plugin1]
}
`),
).toEqual(null)
})
test('bails out on non `require` calls', () => {
expect(
findStaticPlugins(js`
export default {
plugins: [frequire('./plugin1')]
}
`),
).toEqual(null)
})
test('bails out on named imports for plugins', () => {
expect(
findStaticPlugins(js`
import {plugin1} from './plugin1'
export default {
plugins: [plugin1]
}
`),
).toEqual(null)
})
test('bails for plugins with options', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
export default {
plugins: [plugin1({foo:'bar'})]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
export default {
plugins: [require('@tailwindcss/typography')({foo:'bar'})]
}
`),
).toEqual(null)
})
test('returns no plugins if none are exported', () => {
expect(
findStaticPlugins(js`
export default {
plugins: []
}
`),
).toEqual([])
expect(
findStaticPlugins(js`
export default {}
`),
).toEqual([])
})
})
describe('extractStaticImportMap', () => {
test('extracts different kind of imports from an ESM file', () => {
let extracted = extractStaticImportMap(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
import plugin6, { plugin3, plugin4, default as plugin5 } from './plugin3'
import plugin8, * as plugin7 from './plugin7'
`)
expect(extracted).toEqual({
plugin1: { module: './plugin1', export: null },
plugin2: { module: './plugin2', export: '*' },
plugin3: { module: './plugin3', export: 'plugin3' },
plugin4: { module: './plugin3', export: 'plugin4' },
plugin5: { module: './plugin3', export: 'default' },
plugin6: { module: './plugin3', export: null },
plugin7: { module: './plugin7', export: '*' },
plugin8: { module: './plugin7', export: null },
})
})
test('extracts different kind of imports from an CJS file', () => {
let extracted = extractStaticImportMap(js`
const plugin1 = require('./plugin1')
let plugin2 = require('./plugin2')
var plugin3 = require('./plugin3')
const {plugin4, foo: plugin5, ...plugin6} = require('./plugin4')
let {plugin7, foo: plugin8, ...plugin9} = require('./plugin5')
var {plugin10, foo: plugin11, ...plugin12} = require('./plugin6')
`)
expect(extracted).toEqual({
plugin1: { module: './plugin1', export: null },
plugin2: { module: './plugin2', export: null },
plugin3: { module: './plugin3', export: null },
plugin4: { module: './plugin4', export: 'plugin4' },
plugin5: { module: './plugin4', export: 'foo' },
plugin6: { module: './plugin4', export: '*' },
plugin7: { module: './plugin5', export: 'plugin7' },
plugin8: { module: './plugin5', export: 'foo' },
plugin9: { module: './plugin5', export: '*' },
plugin10: { module: './plugin6', export: 'plugin10' },
plugin11: { module: './plugin6', export: 'foo' },
plugin12: { module: './plugin6', export: '*' },
})
})
})

View File

@ -0,0 +1,199 @@
import Parser from 'tree-sitter'
import TS from 'tree-sitter-typescript'
let parser = new Parser()
parser.setLanguage(TS.typescript)
const treesitter = String.raw
const PLUGINS_QUERY = new Parser.Query(
TS.typescript,
treesitter`
; export default {}
(export_statement
value: (satisfies_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
value: (as_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
value: (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
)?
)
; module.exports = {}
(expression_statement
(assignment_expression
left: (member_expression) @left (#eq? @left "module.exports")
right: (satisfies_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
right: (as_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
right: (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
)?
)
)
`,
)
export function findStaticPlugins(source: string): string[] | null {
try {
let tree = parser.parse(source)
let root = tree.rootNode
let imports = extractStaticImportMap(source)
let captures = PLUGINS_QUERY.matches(root)
let plugins = []
for (let match of captures) {
for (let capture of match.captures) {
if (capture.name !== 'imports') continue
for (let pluginDefinition of capture.node.children) {
if (
pluginDefinition.type === '[' ||
pluginDefinition.type === ']' ||
pluginDefinition.type === ','
)
continue
switch (pluginDefinition.type) {
case 'identifier':
let source = imports[pluginDefinition.text]
if (!source || (source.export !== null && source.export !== '*')) {
return null
}
plugins.push(source.module)
break
case 'string':
plugins.push(pluginDefinition.children[1].text)
break
case 'call_expression':
// allow require('..') calls
if (pluginDefinition.children?.[0]?.text !== 'require') return null
let firstArgument = pluginDefinition.children?.[1]?.children?.[1]?.children?.[1]?.text
if (typeof firstArgument !== 'string') return null
plugins.push(firstArgument)
break
default:
return null
}
}
}
}
return plugins
} catch (error) {
console.error(error)
return null
}
}
const IMPORT_QUERY = new Parser.Query(
TS.typescript,
treesitter`
; ESM import
(import_statement
(import_clause
(identifier)? @default
(named_imports
(import_specifier
name: (identifier) @imported-name
alias: (identifier)? @imported-alias
)
)?
(namespace_import (identifier) @imported-namespace)?
)
(string
(string_fragment) @imported-from)
)
; CJS require
(variable_declarator
name: (identifier)? @default
name: (object_pattern
(shorthand_property_identifier_pattern)? @imported-name
(pair_pattern
key: (property_identifier) @imported-name
value: (identifier) @imported-alias
)?
(rest_pattern
(identifier) @imported-namespace
)?
)?
value: (call_expression
function: (identifier) @_fn (#eq? @_fn "require")
arguments: (arguments
(string
(string_fragment) @imported-from
)
)
)
)
`,
)
export function extractStaticImportMap(source: string) {
let tree = parser.parse(source)
let root = tree.rootNode
let captures = IMPORT_QUERY.matches(root)
let imports: Record<string, { module: string; export: string | null }> = {}
for (let match of captures) {
let toImport: { name: string; export: null | string }[] = []
let from = ''
for (let i = 0; i < match.captures.length; i++) {
let capture = match.captures[i]
switch (capture.name) {
case 'default':
toImport.push({ name: capture.node.text, export: null })
break
case 'imported-name':
toImport.push({ name: capture.node.text, export: capture.node.text })
break
case 'imported-from':
from = capture.node.text
break
case 'imported-namespace':
toImport.push({ name: capture.node.text, export: '*' })
break
case 'imported-alias':
if (toImport.length < 1) {
throw new Error('Unexpected alias: ' + JSON.stringify(captures, null, 2))
}
let prevImport = toImport[toImport.length - 1]
let name = prevImport.name
prevImport.export = name
prevImport.name = capture.node.text
break
}
}
for (let { name, export: exportSource } of toImport) {
imports[name] = { module: from, export: exportSource }
}
}
return imports
}

View File

@ -1,3 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"allowSyntheticDefaultImports":true
}
}

41
pnpm-lock.yaml generated
View File

@ -312,6 +312,12 @@ importers:
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
tree-sitter:
specifier: ^0.21.1
version: 0.21.1
tree-sitter-typescript:
specifier: ^0.23.0
version: 0.23.0(tree-sitter@0.21.1)
devDependencies:
'@types/node':
specifier: 'catalog:'
@ -2384,6 +2390,14 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-api@8.1.0:
resolution: {integrity: sha512-yBY+qqWSv3dWKGODD6OGE6GnTX7Q2r+4+DfpqxHSHh8x0B4EKP9+wVGLS6U/AM1vxSNNmUEuIV5EGhYwPpfOwQ==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.2:
resolution: {integrity: sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==}
hasBin: true
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
@ -2917,6 +2931,18 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tree-sitter-typescript@0.23.0:
resolution: {integrity: sha512-hRy5O9d+9ON4HxIWWxkI4zonrw2v/WNN1JoiGW5HkXfC9K2R3p53ugMvs6Vs4T7ASCwggsoQ75LNdgpExC/zgQ==}
peerDependencies:
tree-sitter: ^0.21.0
tree_sitter: '*'
peerDependenciesMeta:
tree_sitter:
optional: true
tree-sitter@0.21.1:
resolution: {integrity: sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==}
ts-api-utils@1.3.0:
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
engines: {node: '>=16'}
@ -5145,6 +5171,10 @@ snapshots:
node-addon-api@7.1.1: {}
node-addon-api@8.1.0: {}
node-gyp-build@4.8.2: {}
node-releases@2.0.18: {}
normalize-path@3.0.0: {}
@ -5677,6 +5707,17 @@ snapshots:
tree-kill@1.2.2: {}
tree-sitter-typescript@0.23.0(tree-sitter@0.21.1):
dependencies:
node-addon-api: 8.1.0
node-gyp-build: 4.8.2
tree-sitter: 0.21.1
tree-sitter@0.21.1:
dependencies:
node-addon-api: 8.1.0
node-gyp-build: 4.8.2
ts-api-utils@1.3.0(typescript@5.5.4):
dependencies:
typescript: 5.5.4