Inject @config "..." when a tailwind.config.{js,ts,...} is detected (#14635)

This PR injects a `@config "…"` in the CSS file if a JS based config has
been found.

We will try to inject the `@config` in a sensible place:
1. Above the very first `@theme`
2. If that doesn't work, below the last `@import`
3. If that doesn't work, at the top of the file (as a last resort)
This commit is contained in:
Robin Malfait 2024-10-10 16:02:42 +02:00 committed by GitHub
parent 4d1becd2f9
commit fb1731a2de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 292 additions and 15 deletions

View File

@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Ensure CSS before a layer stays unlayered when running codemods ([#14596](https://github.com/tailwindlabs/tailwindcss/pull/14596))
- _Upgrade (experimental)_: Resolve issues where some prefixed candidates were not properly migrated ([#14600](https://github.com/tailwindlabs/tailwindcss/pull/14600))
- _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))
## [4.0.0-alpha.26] - 2024-10-03

View File

@ -39,7 +39,9 @@ test(
<div class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"></div>
--- ./src/input.css ---
@import 'tailwindcss';"
@import 'tailwindcss';
@config "../tailwind.config.js";
"
`)
let packageJsonContent = await fs.read('package.json')
@ -95,9 +97,12 @@ test(
--- ./src/input.css ---
@import 'tailwindcss' prefix(tw);
@config "../tailwind.config.js";
.btn {
@apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white;
}"
}
"
`)
},
)
@ -140,6 +145,8 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
@config "../tailwind.config.js";
.a {
@apply flex;
}
@ -150,7 +157,8 @@ test(
.c {
@apply flex! flex-col! items-center!;
}"
}
"
`)
},
)
@ -193,6 +201,8 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
@config "../tailwind.config.js";
@layer base {
html {
color: #333;
@ -203,7 +213,8 @@ test(
.btn {
color: red;
}
}"
}
"
`)
},
)
@ -251,6 +262,8 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
@config "../tailwind.config.js";
@utility btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
@ -261,7 +274,8 @@ test(
}
-ms-overflow-style: none;
scrollbar-width: none;
}"
}
"
`)
},
)
@ -533,7 +547,8 @@ test(
<div class="flex"></div>
--- ./src/other.html ---
<div class="tw:flex"></div>"
<div class="tw:flex"></div>
"
`)
},
)
@ -573,7 +588,8 @@ test(
<div class="tw:bg-linear-to-t"></div>
--- ./src/other.html ---
<div class="bg-gradient-to-t"></div>"
<div class="bg-gradient-to-t"></div>
"
`)
},
)
@ -615,6 +631,7 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
@import './utilities.css';
@config "../tailwind.config.js";
--- ./src/utilities.css ---
@utility no-scrollbar {
@ -623,7 +640,8 @@ test(
}
-ms-overflow-style: none;
scrollbar-width: none;
}"
}
"
`)
},
)
@ -730,6 +748,7 @@ test(
@import './c.1.css' layer(utilities);
@import './c.1.utilities.css';
@import './d.1.css';
@config "../tailwind.config.js";
--- ./src/a.1.css ---
@import './a.1.utilities.css'
@ -804,7 +823,8 @@ test(
--- ./src/d.4.css ---
@utility from-a-4 {
color: blue;
}"
}
"
`)
},
)
@ -862,14 +882,141 @@ test(
--- ./src/root.1.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
@config "../tailwind.config.js";
--- ./src/root.2.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(components);
@config "../tailwind.config.js";
--- ./src/root.3.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);"
@import './a.1.css' layer(utilities);
@config "../tailwind.config.js";
"
`)
},
)
test(
'injecting `@config` when a tailwind.config.{js,ts,…} is detected',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<h1>🤠👋</h1>
<div class="!flex sm:!block bg-gradient-to-t bg-[--my-red]"></div>
`,
'src/root.1.css': css`
/* Inject missing @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/root.2.css': css`
/* Already contains @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
@config "../tailwind.config.js";
`,
'src/root.3.css': css`
/* Inject missing @config above first @theme */
@tailwind base;
@tailwind components;
@tailwind utilities;
@variant hocus (&:hover, &:focus);
@theme {
--color-red-500: #f00;
}
@theme {
--color-blue-500: #00f;
}
`,
'src/root.4.css': css`
/* Inject missing @config due to nested imports with tailwind imports */
@import './root.4/base.css';
@import './root.4/utilities.css';
`,
'src/root.4/base.css': css`@import 'tailwindcss/base';`,
'src/root.4/utilities.css': css`@import 'tailwindcss/utilities';`,
'src/root.5.css': css`@import './root.5/tailwind.css';`,
'src/root.5/tailwind.css': css`
/* Inject missing @config in this file, due to full import */
@import 'tailwindcss';
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<h1>🤠👋</h1>
<div class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"></div>
--- ./src/root.1.css ---
/* Inject missing @config */
@import 'tailwindcss';
@config "../tailwind.config.ts";
--- ./src/root.2.css ---
/* Already contains @config */
@import 'tailwindcss';
@config "../tailwind.config.js";
--- ./src/root.3.css ---
/* Inject missing @config above first @theme */
@import 'tailwindcss';
@config "../tailwind.config.ts";
@variant hocus (&:hover, &:focus);
@theme {
--color-red-500: #f00;
}
@theme {
--color-blue-500: #00f;
}
--- ./src/root.4.css ---
/* Inject missing @config due to nested imports with tailwind imports */
@import './root.4/base.css';
@import './root.4/utilities.css';
@config "../tailwind.config.ts";
--- ./src/root.5.css ---
@import './root.5/tailwind.css';
--- ./src/root.4/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
--- ./src/root.4/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
--- ./src/root.5/tailwind.css ---
/* Inject missing @config in this file, due to full import */
@import 'tailwindcss';
@config "../../tailwind.config.ts";
"
`)
},
)

View File

@ -330,7 +330,8 @@ export function test(
return a[0].localeCompare(z[0])
})
.map(([file, content]) => `--- ${file} ---\n${content || '<EMPTY>'}`)
.join('\n\n')}`
.join('\n\n')
.trim()}\n`
},
async expectFileToContain(filePath, contents) {
return retryAssertion(async () => {

View File

@ -0,0 +1,101 @@
import path from 'node:path'
import { AtRule, type Plugin, type Root } from 'postcss'
import { normalizePath } from '../../../@tailwindcss-node/src/normalize-path'
import type { Stylesheet } from '../stylesheet'
import { walk, WalkAction } from '../utils/walk'
export function migrateAtConfig(
sheet: Stylesheet,
{ configFilePath }: { configFilePath: string },
): Plugin {
function injectInto(sheet: Stylesheet) {
let root = sheet.root
// We don't have a sheet with a file path
if (!sheet.file) return
// Skip if there is already a `@config` directive
{
let hasConfig = false
root.walkAtRules('config', () => {
hasConfig = true
return false
})
if (hasConfig) return
}
// Figure out the path to the config file
let sheetPath = sheet.file
let configPath = configFilePath
let relative = path.relative(path.dirname(sheetPath), configPath)
if (relative[0] !== '.') {
relative = `./${relative}`
}
// Ensure relative is a posix style path since we will merge it with the
// glob.
relative = normalizePath(relative)
// Inject the `@config` in a sensible place
// 1. Below the last `@import`
// 2. At the top of the file
let locationNode = null as AtRule | null
walk(root, (node) => {
if (node.type === 'atrule' && node.name === 'import') {
locationNode = node
}
return WalkAction.Skip
})
let configNode = new AtRule({ name: 'config', params: `"${relative}"` })
if (!locationNode) {
root.prepend(configNode)
} else if (locationNode.name === 'import') {
locationNode.after(configNode)
}
}
function migrate(root: Root) {
// We can only migrate if there is an `@import "tailwindcss"` (or sub-import)
let hasTailwindImport = false
let hasFullTailwindImport = false
root.walkAtRules('import', (node) => {
if (node.params.match(/['"]tailwindcss['"]/)) {
hasTailwindImport = true
hasFullTailwindImport = true
return false
} else if (node.params.match(/['"]tailwindcss\/.*?['"]/)) {
hasTailwindImport = true
}
})
if (!hasTailwindImport) return
// - If a full `@import "tailwindcss"` is present, we can inject the
// `@config` directive directly into this stylesheet.
// - If we are the root file (no parents), then we can inject the `@config`
// directive directly into this file as well.
if (hasFullTailwindImport || sheet.parents.size <= 0) {
injectInto(sheet)
return
}
// Otherwise, if we are not the root file, we need to inject the `@config`
// into the root file.
if (sheet.parents.size > 0) {
for (let parent of sheet.ancestors()) {
if (parent.parents.size === 0) {
injectInto(parent)
}
}
}
}
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-config',
OnceExit: migrate,
}
}

View File

@ -10,7 +10,13 @@ export function migrateMissingLayers(): Plugin {
root.each((node) => {
if (node.type === 'atrule') {
// Known Tailwind directives that should not be inside a layer.
if (node.name === 'theme' || node.name === 'utility') {
if (
node.name === 'config' ||
node.name === 'source' ||
node.name === 'theme' ||
node.name === 'utility' ||
node.name === 'variant'
) {
if (bucket.length > 0) {
buckets.push([lastLayer, bucket.splice(0)])
}

View File

@ -1,5 +1,6 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import path from 'node:path'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import { formatNodes } from './codemods/format-nodes'
@ -13,7 +14,13 @@ let designSystem = await __unstable__loadDesignSystem(
`,
{ base: __dirname },
)
let config = { designSystem, userConfig: {}, newPrefix: null }
let config = {
designSystem,
userConfig: {},
newPrefix: null,
configFilePath: path.resolve(__dirname, './tailwind.config.js'),
}
function migrate(input: string, config: any) {
return migrateContents(input, config, expect.getState().testPath)
@ -87,6 +94,8 @@ it('should migrate a stylesheet', async () => {
).toMatchInlineSnapshot(`
"@import 'tailwindcss';
@config "./tailwind.config.js";
@layer base {
html {
overflow: hidden;
@ -138,7 +147,8 @@ it('should migrate a stylesheet (with imports)', async () => {
"@import 'tailwindcss';
@import './my-base.css' layer(base);
@import './my-components.css' layer(components);
@import './my-utilities.css' layer(utilities);"
@import './my-utilities.css' layer(utilities);
@config "./tailwind.config.js";"
`)
})
@ -163,6 +173,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in
@layer foo, bar, baz;
/**! My license comment */
@import 'tailwindcss';
@config "./tailwind.config.js";
@layer base {
html {
color: red;

View File

@ -5,6 +5,7 @@ import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { DefaultMap } from '../../tailwindcss/src/utils/default-map'
import { segment } from '../../tailwindcss/src/utils/segment'
import { migrateAtApply } from './codemods/migrate-at-apply'
import { migrateAtConfig } from './codemods/migrate-at-config'
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
import { migrateMediaScreen } from './codemods/migrate-media-screen'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
@ -17,6 +18,7 @@ export interface MigrateOptions {
newPrefix: string | null
designSystem: DesignSystem
userConfig: Config
configFilePath: string
}
export async function migrateContents(
@ -35,6 +37,7 @@ export async function migrateContents(
.use(migrateAtLayerUtilities(stylesheet))
.use(migrateMissingLayers())
.use(migrateTailwindDirectives(options))
.use(migrateAtConfig(stylesheet, options))
.process(stylesheet.root, { from: stylesheet.file ?? undefined })
}

View File

@ -22,6 +22,7 @@ export async function prepareConfig(
designSystem: DesignSystem
globs: { base: string; pattern: string }[]
userConfig: Config
configFilePath: string
newPrefix: string | null
}> {
@ -57,7 +58,13 @@ export async function prepareConfig(
__unstable__loadDesignSystem(input, { base: __dirname }),
])
return { designSystem, globs: compiler.globs, userConfig, newPrefix }
return {
designSystem,
globs: compiler.globs,
userConfig,
newPrefix,
configFilePath: fullConfigPath,
}
} catch (e: any) {
error('Could not load the configuration file: ' + e.message)
process.exit(1)