Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include simple config objects when extracting static plugins #14699

Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/@tailwindcss-upgrade/src/migrate-js-config.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}

Original file line number Diff line number Diff line change
@@ -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('bails out on inline plugins', () => {
test('can extract plugin options', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
import plugin1 from './plugin1'
import plugin2 from './plugin2'
export default {
plugins: [plugin1, () => {} ]
}
export default {
plugins: [
plugin1({
foo: 'bar',
}),
plugin2(),
require('./plugin3')({
foo: 'bar',
}),
]
}
`),
).toEqual(null)
).toEqual([
['./plugin1', { foo: 'bar' }],
['./plugin2', null],
['./plugin3', { foo: 'bar' }],
])
})

test('can extract all supported data types', () => {
expect(
findStaticPlugins(js`
let plugin1 = () => {}
import plugin from 'plugin'
export default {
plugins: [plugin1]
}
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')]
}
`),
export default {
plugins: [load('./plugin1')]
}
`),
).toEqual(null)
})

test('bails out on named imports for plugins', () => {
expect(
findStaticPlugins(js`
import {plugin1} from './plugin1'
import {plugin1} from './plugin1'
export default {
plugins: [plugin1]
}
`),
export default {
plugins: [plugin1]
}
`),
).toEqual(null)
})

test('bails for plugins with options', () => {
test('bails on invalid plugin options', () => {
expect(
findStaticPlugins(js`
import plugin1 from './plugin1'
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: [plugin1({foo:'bar'})]
plugins: [
plugin(OPTIONS),
]
}
`),
).toEqual(null)

expect(
findStaticPlugins(js`
import plugin from './plugin'
let something = 1
export default {
plugins: [require('@tailwindcss/typography')({foo:'bar'})]
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([])
})
})
263 changes: 223 additions & 40 deletions packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts
Original file line number Diff line number Diff line change
@@ -5,66 +5,145 @@ 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: (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
value: [
(satisfies_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
right: (as_expression (object
))
value: (as_expression (object
(pair
key: (property_identifier) @_name (#eq? @_name "plugins")
value: (array) @imports
)
))?
right: (object
))
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
)
))
(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
)
)
]
)
)
`,
)
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

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
}