diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index 6ca267e58..a4fab0f17 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -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 }) } } diff --git a/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.test.ts b/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.test.ts index 8f8892cb3..4ec33b410 100644 --- a/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.test.ts +++ b/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.test.ts @@ -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([]) }) }) diff --git a/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts b/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts index 4034ac42e..d0f6dbf8e 100644 --- a/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts +++ b/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts @@ -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 +> + +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 = [] + 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 +}