Include simple config objects when extracting static plugins (#14699)

This PR updates the `extractStaticPlugins` function to also emit options as long as these are objects containing of only `string` and `number` values.

While doing this I also cleaned up the `require('custom-plugin')` detector to use a Tree-Sitter query instead of operating on the AST.

Here are the two cases we considered:

```js
import plugin1 from 'plugin1';

export default {
  plugins: [
    plugin1({
      foo: 'bar',
      num: 19,
    }),
    require('./plugin2')({
      foo: 'bar',
      num: 19,
    }),
  ]
}
```

The test plan also contains a number of scenarios that we do not want to migrate to CSS (because we do not have a CSS API we can use for e.g. nested objects). We do support all types that we also support in the CSS API.
This commit is contained in:
Philipp Spiess 2024-10-18 15:10:21 +02:00 committed by GitHub
parent b701ed6916
commit 3e7695fb2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 448 additions and 112 deletions

View File

@ -14,7 +14,7 @@ import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/con
import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { findStaticPlugins } from './utils/extract-static-plugins'
import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins'
import { info } from './utils/renderer'
const __filename = fileURLToPath(import.meta.url)
@ -46,7 +46,7 @@ export async function migrateJsConfig(
}
let sources: { base: string; pattern: string }[] = []
let plugins: { base: string; path: string }[] = []
let plugins: { base: string; path: string; options: null | StaticPluginOptions }[] = []
let cssConfigs: string[] = []
if ('darkMode' in unresolvedConfig) {
@ -64,8 +64,8 @@ export async function migrateJsConfig(
let simplePlugins = findStaticPlugins(source)
if (simplePlugins !== null) {
for (let plugin of simplePlugins) {
plugins.push({ base, path: plugin })
for (let [path, options] of simplePlugins) {
plugins.push({ base, path, options })
}
}

View File

@ -9,35 +9,44 @@ describe('findStaticPlugins', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
plugins: [plugin1, 'plugin2', require('./plugin3')]
}
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
plugins: [plugin1, 'plugin2', require('./plugin3')]
} as any
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import * as plugin2 from './plugin2'
export default {
plugins: [plugin1, plugin2, 'plugin3', require('./plugin4')]
plugins: [plugin1, 'plugin2', require('./plugin3')]
} satisfies any
`),
).toEqual(['./plugin1', './plugin2', 'plugin3', './plugin4'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
expect(
findStaticPlugins(js`
@ -47,7 +56,11 @@ describe('findStaticPlugins', () => {
plugins: [plugin1, 'plugin2', require('./plugin3')]
} as any
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
expect(
findStaticPlugins(js`
@ -57,7 +70,11 @@ describe('findStaticPlugins', () => {
plugins: [plugin1, 'plugin2', require('./plugin3')]
} satisfies any
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
expect(
findStaticPlugins(js`
@ -67,68 +84,204 @@ describe('findStaticPlugins', () => {
plugins: [plugin1, 'plugin2', require('./plugin3')]
}
`),
).toEqual(['./plugin1', 'plugin2', './plugin3'])
).toEqual([
['./plugin1', null],
['plugin2', null],
['./plugin3', null],
])
})
test('can extract plugin options', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import plugin2 from './plugin2'
export default {
plugins: [
plugin1({
foo: 'bar',
}),
plugin2(),
require('./plugin3')({
foo: 'bar',
}),
]
}
`),
).toEqual([
['./plugin1', { foo: 'bar' }],
['./plugin2', null],
['./plugin3', { foo: 'bar' }],
])
})
test('can extract all supported data types', () => {
expect(
findStaticPlugins(js`
import plugin from 'plugin'
export default {
plugins: [
plugin({
'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'],
'is-arr': ['foo', 'bar'],
'is-null': null,
'is-true': true,
'is-false': false,
'is-int': 1234567,
'is-float': 1.35,
'is-sci': 1.35e-5,
'is-str-null': 'null',
'is-str-true': 'true',
'is-str-false': 'false',
'is-str-int': '1234567',
'is-str-float': '1.35',
'is-str-sci': '1.35e-5',
}),
]
}
`),
).toEqual([
[
'plugin',
{
'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'],
'is-arr': ['foo', 'bar'],
'is-null': null,
'is-true': true,
'is-false': false,
'is-int': 1234567,
'is-float': 1.35,
'is-sci': 1.35e-5,
'is-str-null': 'null',
'is-str-true': 'true',
'is-str-false': 'false',
'is-str-int': '1234567',
'is-str-float': '1.35',
'is-str-sci': '1.35e-5',
},
],
])
})
test('bails out on import * as import', () => {
expect(
findStaticPlugins(js`
import * as plugin from './plugin'
export default {
plugins: [plugin]
}
`),
).toEqual(null)
})
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'})]
plugins: [plugin1, () => {} ]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
let plugin1 = () => {}
export default {
plugins: [require('@tailwindcss/typography')({foo:'bar'})]
plugins: [plugin1]
}
`),
).toEqual(null)
})
test('bails out on non `require` calls', () => {
expect(
findStaticPlugins(js`
export default {
plugins: [load('./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 on invalid plugin options', () => {
expect(
findStaticPlugins(js`
import plugin from './plugin'
export default {
plugins: [
plugin({ foo }),
]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
import plugin from './plugin'
export default {
plugins: [
plugin({ foo: { bar: 2 } }),
]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
import plugin from './plugin'
export default {
plugins: [
plugin({ foo: ${'`bar${""}`'} }),
]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
import plugin from './plugin'
const OPTIONS = { foo: 1 }
export default {
plugins: [
plugin(OPTIONS),
]
}
`),
).toEqual(null)
expect(
findStaticPlugins(js`
import plugin from './plugin'
let something = 1
export default {
plugins: [
plugin({ foo: something }),
]
}
`),
).toEqual(null)
@ -137,16 +290,16 @@ describe('findStaticPlugins', () => {
test('returns no plugins if none are exported', () => {
expect(
findStaticPlugins(js`
export default {
plugins: []
}
`),
export default {
plugins: []
}
`),
).toEqual([])
expect(
findStaticPlugins(js`
export default {}
`),
export default {}
`),
).toEqual([])
})
})

View File

@ -5,58 +5,137 @@ let parser = new Parser()
parser.setLanguage(TS.typescript)
const treesitter = String.raw
// Extract `plugins` property of the object export for both ESM and CJS files
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: [
(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
)
)
))?
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: [
(satisfies_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))
(as_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))
(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 {
// Extract require() calls, as well as identifiers with options or require()
// with options
const PLUGIN_CALL_OPTIONS_QUERY = new Parser.Query(
TS.typescript,
treesitter`
(call_expression
function: [
(call_expression
function: (identifier) @_name (#eq? @_name "require")
arguments: (arguments
(string (string_fragment) @module_string)
)
)
(identifier) @module_identifier
]
arguments: [
(arguments
(object
(pair
key: [
(property_identifier) @property
(string (string_fragment) @property)
]
value: [
(string (string_fragment) @str_value)
(template_string
. (string_fragment) @str_value
; If the template string has more than exactly one string
; fragment at the top, the migration should bail.
_ @error
)
(number) @num_value
(true) @true_value
(false) @false_value
(null) @null_value
(array [
(string (string_fragment) @str_value)
(template_string (string_fragment) @str_value)
(number) @num_value
(true) @true_value
(false) @false_value
(null) @null_value
]) @array_value
]
)
)
)
(arguments) @_empty_args (#eq? @_empty_args "()")
]
)
(call_expression
function: (identifier) @_name (#eq? @_name "require")
arguments: (arguments
(string (string_fragment) @module_string)
)
)
`,
)
export type StaticPluginOptions = Record<
string,
| string
| number
| boolean
| null
| string
| number
| boolean
| null
| Array<string | number | boolean | null>
>
export function findStaticPlugins(source: string): [string, null | StaticPluginOptions][] | null {
try {
let tree = parser.parse(source)
let root = tree.rootNode
@ -64,7 +143,7 @@ export function findStaticPlugins(source: string): string[] | null {
let imports = extractStaticImportMap(source)
let captures = PLUGINS_QUERY.matches(root)
let plugins = []
let plugins: [string, null | StaticPluginOptions][] = []
for (let match of captures) {
for (let capture of match.captures) {
if (capture.name !== 'imports') continue
@ -80,20 +159,111 @@ export function findStaticPlugins(source: string): string[] | null {
switch (pluginDefinition.type) {
case 'identifier':
let source = imports[pluginDefinition.text]
if (!source || (source.export !== null && source.export !== '*')) {
if (!source || source.export !== null) {
return null
}
plugins.push(source.module)
plugins.push([source.module, null])
break
case 'string':
plugins.push(pluginDefinition.children[1].text)
plugins.push([pluginDefinition.children[1].text, null])
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)
let matches = PLUGIN_CALL_OPTIONS_QUERY.matches(pluginDefinition)
if (matches.length === 0) return null
let moduleName: string | null = null
let moduleIdentifier: string | null = null
let options: StaticPluginOptions | null = null
let lastProperty: string | null = null
let captures = matches.flatMap((m) => m.captures)
for (let i = 0; i < captures.length; i++) {
let capture = captures[i]
switch (capture.name) {
case 'module_identifier': {
moduleIdentifier = capture.node.text
break
}
case 'module_string': {
moduleName = capture.node.text
break
}
case 'property': {
if (lastProperty !== null) return null
lastProperty = capture.node.text
break
}
case 'str_value':
case 'num_value':
case 'null_value':
case 'true_value':
case 'false_value': {
if (lastProperty === null) return null
options ??= {}
options[lastProperty] = extractValue(capture)
lastProperty = null
break
}
case 'array_value': {
if (lastProperty === null) return null
options ??= {}
// Loop over all captures after this one that are on the
// same property (it will be one match for any array
// element)
let array: Array<string | number | boolean | null> = []
let lastConsumedIndex = i
arrayLoop: for (let j = i + 1; j < captures.length; j++) {
let innerCapture = captures[j]
switch (innerCapture.name) {
case 'property': {
if (innerCapture.node.text !== lastProperty) {
break arrayLoop
}
break
}
case 'str_value':
case 'num_value':
case 'null_value':
case 'true_value':
case 'false_value': {
array.push(extractValue(innerCapture))
lastConsumedIndex = j
}
}
}
i = lastConsumedIndex
options[lastProperty] = array
lastProperty = null
break
}
case '_name':
case '_empty_args':
break
default:
return null
}
}
if (lastProperty !== null) return null
if (moduleIdentifier !== null) {
let source = imports[moduleIdentifier]
if (!source || (source.export !== null && source.export !== '*')) {
return null
}
moduleName = source.module
}
if (moduleName === null) {
return null
}
plugins.push([moduleName, options])
break
default:
return null
@ -108,6 +278,7 @@ export function findStaticPlugins(source: string): string[] | null {
}
}
// Extract all top-level imports for both ESM and CJS files
const IMPORT_QUERY = new Parser.Query(
TS.typescript,
treesitter`
@ -197,3 +368,15 @@ export function extractStaticImportMap(source: string) {
return imports
}
function extractValue(capture: { name: string; node: { text: string } }) {
return capture.name === 'num_value'
? parseFloat(capture.node.text)
: capture.name === 'null_value'
? null
: capture.name === 'true_value'
? true
: capture.name === 'false_value'
? false
: capture.node.text
}