mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2026-02-01 17:26:34 +00:00
Add setup for template migrations (#14502)
This PR adds the initial setup and a first codemod for the template migrations. These are a new set of migrations that operate on files defined in the Tailwind v3 config as part of the `content` option (so your HTML, JavaScript, TSX files etc.). The migration for this is integrated in the new `@tailwindcss/upgrade` package and will require pointing the migration to an input JavaScript config file, like this: ``` npx @tailwindcss/upgrade --config tailwind.config.js ``` The idea of template migrations is to apply breaking changes from the v3 to v4 migration within your template files. ## Migrating !important syntax The first migration that I’m adding with this PR is to ensure we use the v4 important syntax that has the exclamation mark at the end of the utility. For example, this: ```html <div class="!flex sm:!block"></div> ``` Will now turn into: ```html <div class="flex! sm:block!"></div> ``` ## Architecture considerations Implementation wise, we make use of Oxide to scan the content files fast and efficiently. By relying on the same scanner als Tailwind v4, we guarantee that all candidates that are part of the v4 output will have gone through a migration. Migrations itself operate on the abstract `Candidate` type, similar to the type we use in the v4 codebase. It will parse the candidate into its parts so they can easily be introspected/modified. Migrations are typed as: ```ts type TemplateMigration = (candidate: Candidate) => Candidate | null ``` `null` should be returned if the `Candidate` does not need a migration. We currently use the v4 `parseCandidate` function to get an abstract definition of the candidate rule that we can operate on. _This will likely need to change in the future as we need to fork `parseCandidate` for v3 specific syntax_. Additionally, we're inlining a `printCandidate` function that can stringify the abstract `Candidate` type. It is not guaranteed that this is an identity function since some information can be lost during the parse step. This is not a problem though, because migrations will only run selectively and if none of the selectors trigger, the candidates are not updated. h/t to @RobinMalfait for providing the printer. So the overall flow of a migration looks like this: - Scan the config file for `content` files - Use Oxide to extract a list of candidate and their positions from these `content` files - Run a few migrations that operate on the `Candidate` abstract type. - Print the updated `Candidate` back into the original `content` file. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com> Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
parent
a144360217
commit
732147a761
@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434))
|
||||
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504))
|
||||
- _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))
|
||||
- _Experimental_: Add template codemods for migrating important utilities (e.g. `!flex` to `flex!`) ([#14502](https://github.com/tailwindlabs/tailwindcss/pull/14502))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@ -82,6 +82,16 @@ pub struct Scanner {
|
||||
scanner: tailwindcss_oxide::Scanner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[napi(object)]
|
||||
pub struct CandidateWithPosition {
|
||||
/// The candidate string
|
||||
pub candidate: String,
|
||||
|
||||
/// The position of the candidate inside the content file
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Scanner {
|
||||
#[napi(constructor)]
|
||||
@ -108,6 +118,22 @@ impl Scanner {
|
||||
.scan_content(input.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_candidates_with_positions(
|
||||
&mut self,
|
||||
input: ChangedContent,
|
||||
) -> Vec<CandidateWithPosition> {
|
||||
self
|
||||
.scanner
|
||||
.get_candidates_with_positions(input.into())
|
||||
.into_iter()
|
||||
.map(|(candidate, position)| CandidateWithPosition {
|
||||
candidate,
|
||||
position: position as i64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn files(&mut self) -> Vec<String> {
|
||||
self.scanner.get_files()
|
||||
|
||||
@ -124,6 +124,28 @@ impl Scanner {
|
||||
new_candidates
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn get_candidates_with_positions(
|
||||
&mut self,
|
||||
changed_content: ChangedContent,
|
||||
) -> Vec<(String, usize)> {
|
||||
self.prepare();
|
||||
|
||||
let content = read_changed_content(changed_content).unwrap_or_default();
|
||||
let extractor = Extractor::with_positions(&content[..], Default::default());
|
||||
|
||||
let candidates: Vec<(String, usize)> = extractor
|
||||
.into_iter()
|
||||
.map(|(s, i)| {
|
||||
// SAFETY: When we parsed the candidates, we already guaranteed that the byte slices
|
||||
// are valid, therefore we don't have to re-check here when we want to convert it back
|
||||
// to a string.
|
||||
unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }
|
||||
})
|
||||
.collect();
|
||||
candidates
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn get_files(&mut self) -> Vec<String> {
|
||||
self.prepare();
|
||||
|
||||
@ -82,6 +82,18 @@ impl<'a> Extractor<'a> {
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
pub fn with_positions(input: &'a [u8], opts: ExtractorOptions) -> Vec<(&'a [u8], usize)> {
|
||||
let mut result = Vec::new();
|
||||
let extractor = Self::new(input, opts).flatten();
|
||||
for item in extractor {
|
||||
// Since the items are slices of the input buffer, we can calculate the start index
|
||||
// by doing some pointer arithmetics.
|
||||
let start_index = item.as_ptr() as usize - input.as_ptr() as usize;
|
||||
result.push((item, start_index));
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Extractor<'a> {
|
||||
|
||||
@ -1,4 +1,47 @@
|
||||
import { css, json, test } from '../utils'
|
||||
import { css, html, js, json, test } from '../utils'
|
||||
|
||||
test(
|
||||
`upgrades a v3 project to v4`,
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,js}'],
|
||||
}
|
||||
`,
|
||||
'src/index.html': html`
|
||||
<h1>🤠👋</h1>
|
||||
<div class="!flex sm:!block"></div>
|
||||
`,
|
||||
'src/input.css': css`
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade -c tailwind.config.js')
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/index.html',
|
||||
html`
|
||||
<h1>🤠👋</h1>
|
||||
<div class="flex! sm:block!"></div>
|
||||
`,
|
||||
)
|
||||
|
||||
await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'migrate @apply',
|
||||
@ -4,7 +4,10 @@ import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path, { dirname, extname } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { compile as _compile } from 'tailwindcss'
|
||||
import {
|
||||
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
|
||||
compile as _compile,
|
||||
} from 'tailwindcss'
|
||||
import { getModuleDependencies } from './get-module-dependencies'
|
||||
|
||||
export async function compile(
|
||||
@ -14,59 +17,78 @@ export async function compile(
|
||||
return await _compile(css, {
|
||||
base,
|
||||
async loadModule(id, base) {
|
||||
if (id[0] !== '.') {
|
||||
let resolvedPath = await resolveJsId(id, base)
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
||||
}
|
||||
|
||||
let module = await importModule(pathToFileURL(resolvedPath).href)
|
||||
return {
|
||||
base: dirname(resolvedPath),
|
||||
module: module.default ?? module,
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedPath = await resolveJsId(id, base)
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
||||
}
|
||||
let [module, moduleDependencies] = await Promise.all([
|
||||
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
|
||||
getModuleDependencies(resolvedPath),
|
||||
])
|
||||
|
||||
onDependency(resolvedPath)
|
||||
for (let file of moduleDependencies) {
|
||||
onDependency(file)
|
||||
}
|
||||
return {
|
||||
base: dirname(resolvedPath),
|
||||
module: module.default ?? module,
|
||||
}
|
||||
return loadModule(id, base, onDependency)
|
||||
},
|
||||
async loadStylesheet(id, base) {
|
||||
return loadStylesheet(id, base, onDependency)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async loadStylesheet(id, basedir) {
|
||||
let resolvedPath = await resolveCssId(id, basedir)
|
||||
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${basedir}'`)
|
||||
export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) {
|
||||
return ___unstable__loadDesignSystem(css, {
|
||||
base,
|
||||
async loadModule(id, base) {
|
||||
return loadModule(id, base, () => {})
|
||||
},
|
||||
async loadStylesheet(id, base) {
|
||||
return loadStylesheet(id, base, () => {})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof globalThis.__tw_readFile === 'function') {
|
||||
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
|
||||
if (file) {
|
||||
return {
|
||||
base: path.dirname(resolvedPath),
|
||||
content: file,
|
||||
}
|
||||
}
|
||||
}
|
||||
async function loadModule(id: string, base: string, onDependency: (path: string) => void) {
|
||||
if (id[0] !== '.') {
|
||||
let resolvedPath = await resolveJsId(id, base)
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
||||
}
|
||||
|
||||
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
|
||||
let module = await importModule(pathToFileURL(resolvedPath).href)
|
||||
return {
|
||||
base: dirname(resolvedPath),
|
||||
module: module.default ?? module,
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedPath = await resolveJsId(id, base)
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
||||
}
|
||||
let [module, moduleDependencies] = await Promise.all([
|
||||
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
|
||||
getModuleDependencies(resolvedPath),
|
||||
])
|
||||
|
||||
onDependency(resolvedPath)
|
||||
for (let file of moduleDependencies) {
|
||||
onDependency(file)
|
||||
}
|
||||
return {
|
||||
base: dirname(resolvedPath),
|
||||
module: module.default ?? module,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStylesheet(id: string, base: string, onDependency: (path: string) => void) {
|
||||
let resolvedPath = await resolveCssId(id, base)
|
||||
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`)
|
||||
|
||||
if (typeof globalThis.__tw_readFile === 'function') {
|
||||
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
|
||||
if (file) {
|
||||
return {
|
||||
base: path.dirname(resolvedPath),
|
||||
content: file,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
|
||||
return {
|
||||
base: path.dirname(resolvedPath),
|
||||
content: file,
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts to import the module using the native `import()` function. If this
|
||||
|
||||
@ -27,6 +27,8 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "workspace:^",
|
||||
"@tailwindcss/oxide": "workspace:^",
|
||||
"enhanced-resolve": "^5.17.1",
|
||||
"globby": "^14.0.2",
|
||||
"mri": "^1.2.0",
|
||||
@ -35,6 +37,7 @@
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-selector-parser": "^6.1.2",
|
||||
"prettier": "^3.3.3",
|
||||
"string-byte-slice": "^3.0.0",
|
||||
"tailwindcss": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -2,13 +2,17 @@
|
||||
|
||||
import { globby } from 'globby'
|
||||
import path from 'node:path'
|
||||
import type { DesignSystem } from '../../tailwindcss/src/design-system'
|
||||
import { help } from './commands/help'
|
||||
import { migrate } from './migrate'
|
||||
import { migrate as migrateStylesheet } from './migrate'
|
||||
import { migrate as migrateTemplate } from './template/migrate'
|
||||
import { parseConfig } from './template/parseConfig'
|
||||
import { args, type Arg } from './utils/args'
|
||||
import { isRepoDirty } from './utils/git'
|
||||
import { eprintln, error, header, highlight, info, success } from './utils/renderer'
|
||||
|
||||
const options = {
|
||||
'--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' },
|
||||
'--help': { type: 'boolean', description: 'Display usage information', alias: '-h' },
|
||||
'--force': { type: 'boolean', description: 'Force the migration', alias: '-f' },
|
||||
'--version': { type: 'boolean', description: 'Display the version number', alias: '-v' },
|
||||
@ -37,32 +41,78 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided files
|
||||
let files = flags._.map((file) => path.resolve(process.cwd(), file))
|
||||
|
||||
// Discover CSS files in case no files were provided
|
||||
if (files.length === 0) {
|
||||
info(
|
||||
'No files provided. Searching for CSS files in the current directory and its subdirectories…',
|
||||
)
|
||||
|
||||
files = await globby(['**/*.css'], {
|
||||
absolute: true,
|
||||
gitignore: true,
|
||||
})
|
||||
let parsedConfig: {
|
||||
designSystem: DesignSystem
|
||||
globs: { pattern: string; base: string }[]
|
||||
} | null = null
|
||||
if (flags['--config']) {
|
||||
try {
|
||||
parsedConfig = await parseConfig(flags['--config'], { base: process.cwd() })
|
||||
} catch (e: any) {
|
||||
error(`Failed to parse the configuration file: ${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we are only dealing with CSS files
|
||||
files = files.filter((file) => file.endsWith('.css'))
|
||||
if (parsedConfig) {
|
||||
// Template migrations
|
||||
|
||||
// Migrate each file
|
||||
await Promise.allSettled(files.map((file) => migrate(file)))
|
||||
info('Migrating templates using the provided configuration file.')
|
||||
|
||||
let set = new Set<string>()
|
||||
for (let { pattern, base } of parsedConfig.globs) {
|
||||
let files = await globby([pattern], {
|
||||
absolute: true,
|
||||
gitignore: true,
|
||||
cwd: base,
|
||||
})
|
||||
|
||||
for (let file of files) {
|
||||
set.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
let files = Array.from(set)
|
||||
files.sort()
|
||||
|
||||
// Migrate each file
|
||||
await Promise.allSettled(files.map((file) => migrateTemplate(parsedConfig.designSystem, file)))
|
||||
|
||||
success('Template migration complete.')
|
||||
}
|
||||
|
||||
{
|
||||
// Stylesheet migrations
|
||||
|
||||
// Use provided files
|
||||
let files = flags._.map((file) => path.resolve(process.cwd(), file))
|
||||
|
||||
// Discover CSS files in case no files were provided
|
||||
if (files.length === 0) {
|
||||
info(
|
||||
'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…',
|
||||
)
|
||||
|
||||
files = await globby(['**/*.css'], {
|
||||
absolute: true,
|
||||
gitignore: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure we are only dealing with CSS files
|
||||
files = files.filter((file) => file.endsWith('.css'))
|
||||
|
||||
// Migrate each file
|
||||
await Promise.allSettled(files.map((file) => migrateStylesheet(file)))
|
||||
|
||||
success('Stylesheet migration complete.')
|
||||
}
|
||||
|
||||
// Figure out if we made any changes
|
||||
if (isRepoDirty()) {
|
||||
success('Migration complete. Verify the changes and commit them to your repository.')
|
||||
success('Verify the changes and commit them to your repository.')
|
||||
} else {
|
||||
success('Migration complete. No changes were made to your repository.')
|
||||
success('No changes were made to your repository.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
212
packages/@tailwindcss-upgrade/src/template/candidates.test.ts
Normal file
212
packages/@tailwindcss-upgrade/src/template/candidates.test.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
|
||||
|
||||
let html = String.raw
|
||||
|
||||
test('extracts candidates with positions from a template', async () => {
|
||||
let content = html`
|
||||
<div class="bg-blue-500 hover:focus:text-white [color:red]">
|
||||
<button class="bg-blue-500 text-white">My button</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(extractCandidates(designSystem, content)).resolves.toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"candidate": {
|
||||
"important": false,
|
||||
"kind": "functional",
|
||||
"modifier": null,
|
||||
"negative": false,
|
||||
"raw": "bg-blue-500",
|
||||
"root": "bg",
|
||||
"value": {
|
||||
"fraction": null,
|
||||
"kind": "named",
|
||||
"value": "blue-500",
|
||||
},
|
||||
"variants": [],
|
||||
},
|
||||
"end": 28,
|
||||
"start": 17,
|
||||
},
|
||||
{
|
||||
"candidate": {
|
||||
"important": false,
|
||||
"kind": "functional",
|
||||
"modifier": null,
|
||||
"negative": false,
|
||||
"raw": "hover:focus:text-white",
|
||||
"root": "text",
|
||||
"value": {
|
||||
"fraction": null,
|
||||
"kind": "named",
|
||||
"value": "white",
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"compounds": true,
|
||||
"kind": "static",
|
||||
"root": "focus",
|
||||
},
|
||||
{
|
||||
"compounds": true,
|
||||
"kind": "static",
|
||||
"root": "hover",
|
||||
},
|
||||
],
|
||||
},
|
||||
"end": 51,
|
||||
"start": 29,
|
||||
},
|
||||
{
|
||||
"candidate": {
|
||||
"important": false,
|
||||
"kind": "arbitrary",
|
||||
"modifier": null,
|
||||
"property": "color",
|
||||
"raw": "[color:red]",
|
||||
"value": "red",
|
||||
"variants": [],
|
||||
},
|
||||
"end": 63,
|
||||
"start": 52,
|
||||
},
|
||||
{
|
||||
"candidate": {
|
||||
"important": false,
|
||||
"kind": "functional",
|
||||
"modifier": null,
|
||||
"negative": false,
|
||||
"raw": "bg-blue-500",
|
||||
"root": "bg",
|
||||
"value": {
|
||||
"fraction": null,
|
||||
"kind": "named",
|
||||
"value": "blue-500",
|
||||
},
|
||||
"variants": [],
|
||||
},
|
||||
"end": 98,
|
||||
"start": 87,
|
||||
},
|
||||
{
|
||||
"candidate": {
|
||||
"important": false,
|
||||
"kind": "functional",
|
||||
"modifier": null,
|
||||
"negative": false,
|
||||
"raw": "text-white",
|
||||
"root": "text",
|
||||
"value": {
|
||||
"fraction": null,
|
||||
"kind": "named",
|
||||
"value": "white",
|
||||
},
|
||||
"variants": [],
|
||||
},
|
||||
"end": 109,
|
||||
"start": 99,
|
||||
},
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
test('replaces the right positions for a candidate', async () => {
|
||||
let content = html`
|
||||
<h1>🤠👋</h1>
|
||||
<div class="bg-blue-500" />
|
||||
`
|
||||
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let candidate = (await extractCandidates(designSystem, content))[0]
|
||||
|
||||
expect(replaceCandidateInContent(content, 'flex', candidate.start, candidate.end))
|
||||
.toMatchInlineSnapshot(`
|
||||
"
|
||||
<h1>🤠👋</h1>
|
||||
<div class="flex" />
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
const candidates = [
|
||||
// Arbitrary candidates
|
||||
['[color:red]', '[color:red]'],
|
||||
['[color:red]/50', '[color:red]/50'],
|
||||
['[color:red]/[0.5]', '[color:red]/[0.5]'],
|
||||
['[color:red]/50!', '[color:red]/50!'],
|
||||
['![color:red]/50', '[color:red]/50!'],
|
||||
['[color:red]/[0.5]!', '[color:red]/[0.5]!'],
|
||||
|
||||
// Static candidates
|
||||
['box-border', 'box-border'],
|
||||
['underline!', 'underline!'],
|
||||
['!underline', 'underline!'],
|
||||
['-inset-full', '-inset-full'],
|
||||
|
||||
// Functional candidates
|
||||
['bg-red-500', 'bg-red-500'],
|
||||
['bg-red-500/50', 'bg-red-500/50'],
|
||||
['bg-red-500/[0.5]', 'bg-red-500/[0.5]'],
|
||||
['bg-red-500!', 'bg-red-500!'],
|
||||
['!bg-red-500', 'bg-red-500!'],
|
||||
['bg-[#0088cc]/50', 'bg-[#0088cc]/50'],
|
||||
['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'],
|
||||
['bg-[#0088cc]!', 'bg-[#0088cc]!'],
|
||||
['!bg-[#0088cc]', 'bg-[#0088cc]!'],
|
||||
['w-1/2', 'w-1/2'],
|
||||
]
|
||||
|
||||
const variants = [
|
||||
'', // no variant
|
||||
'*:',
|
||||
'focus:',
|
||||
'group-focus:',
|
||||
|
||||
'hover:focus:',
|
||||
'hover:group-focus:',
|
||||
'group-hover:focus:',
|
||||
'group-hover:group-focus:',
|
||||
|
||||
'min-[10px]:',
|
||||
// TODO: This currently expands `calc(1000px+12em)` to `calc(1000px_+_12em)`
|
||||
'min-[calc(1000px_+_12em)]:',
|
||||
|
||||
'peer-[&_p]:',
|
||||
'peer-[&_p]:hover:',
|
||||
'hover:peer-[&_p]:',
|
||||
'hover:peer-[&_p]:focus:',
|
||||
'peer-[&:hover]:peer-[&_p]:',
|
||||
]
|
||||
|
||||
let combinations: [string, string][] = []
|
||||
for (let variant of variants) {
|
||||
for (let [input, output] of candidates) {
|
||||
combinations.push([`${variant}${input}`, `${variant}${output}`])
|
||||
}
|
||||
}
|
||||
|
||||
describe('toString()', () => {
|
||||
test.each(combinations)('%s', async (candidate: string, result: string) => {
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let candidates = designSystem.parseCandidate(candidate)
|
||||
|
||||
// Sometimes we will have a functional and a static candidate for the same
|
||||
// raw input string (e.g. `-inset-full`). Dedupe in this case.
|
||||
let cleaned = new Set([...candidates].map(printCandidate))
|
||||
|
||||
expect([...cleaned]).toEqual([result])
|
||||
})
|
||||
})
|
||||
148
packages/@tailwindcss-upgrade/src/template/candidates.ts
Normal file
148
packages/@tailwindcss-upgrade/src/template/candidates.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Scanner } from '@tailwindcss/oxide'
|
||||
import stringByteSlice from 'string-byte-slice'
|
||||
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
|
||||
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
|
||||
|
||||
export async function extractCandidates(
|
||||
designSystem: DesignSystem,
|
||||
content: string,
|
||||
): Promise<{ candidate: Candidate; start: number; end: number }[]> {
|
||||
let scanner = new Scanner({})
|
||||
let result = scanner.getCandidatesWithPositions({ content, extension: 'html' })
|
||||
|
||||
let candidates: { candidate: Candidate; start: number; end: number }[] = []
|
||||
for (let { candidate: rawCandidate, position: start } of result) {
|
||||
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
candidates.push({ candidate, start, end: start + rawCandidate.length })
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function printCandidate(candidate: Candidate | null) {
|
||||
if (candidate === null) return 'null'
|
||||
let parts: string[] = []
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
parts.unshift(printVariant(variant))
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle negative
|
||||
if (candidate.kind === 'static' || candidate.kind === 'functional') {
|
||||
if (candidate.negative) {
|
||||
base += '-'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle static
|
||||
if (candidate.kind === 'static') {
|
||||
base += candidate.root
|
||||
}
|
||||
|
||||
// Handle functional
|
||||
if (candidate.kind === 'functional') {
|
||||
base += candidate.root
|
||||
|
||||
if (candidate.value) {
|
||||
if (candidate.value.kind === 'arbitrary') {
|
||||
if (candidate.value === null) {
|
||||
base += ''
|
||||
} else if (candidate.value.dataType) {
|
||||
base += `-[${candidate.value.dataType}:${escapeArbitrary(candidate.value.value)}]`
|
||||
} else {
|
||||
base += `-[${escapeArbitrary(candidate.value.value)}]`
|
||||
}
|
||||
} else if (candidate.value.kind === 'named') {
|
||||
base += `-${candidate.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arbitrary
|
||||
if (candidate.kind === 'arbitrary') {
|
||||
base += `[${candidate.property}:${escapeArbitrary(candidate.value)}]`
|
||||
}
|
||||
|
||||
// Handle modifier
|
||||
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
|
||||
if (candidate.modifier) {
|
||||
if (candidate.modifier.kind === 'arbitrary') {
|
||||
base += `/[${escapeArbitrary(candidate.modifier.value)}]`
|
||||
} else if (candidate.modifier.kind === 'named') {
|
||||
base += `/${candidate.modifier.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle important
|
||||
if (candidate.important) {
|
||||
base += '!'
|
||||
}
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join(':')
|
||||
}
|
||||
|
||||
function printVariant(variant: Variant) {
|
||||
// Handle static variants
|
||||
if (variant.kind === 'static') {
|
||||
return variant.root
|
||||
}
|
||||
|
||||
// Handle arbitrary variants
|
||||
if (variant.kind === 'arbitrary') {
|
||||
return `[${escapeArbitrary(variant.selector)}]`
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle functional variants
|
||||
if (variant.kind === 'functional') {
|
||||
base += variant.root
|
||||
if (variant.value) {
|
||||
if (variant.value.kind === 'arbitrary') {
|
||||
base += `-[${escapeArbitrary(variant.value.value)}]`
|
||||
} else if (variant.value.kind === 'named') {
|
||||
base += `-${variant.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compound variants
|
||||
if (variant.kind === 'compound') {
|
||||
base += variant.root
|
||||
base += '-'
|
||||
base += printVariant(variant.variant)
|
||||
}
|
||||
|
||||
// Handle modifiers
|
||||
if (variant.kind === 'functional' || variant.kind === 'compound') {
|
||||
if (variant.modifier) {
|
||||
if (variant.modifier.kind === 'arbitrary') {
|
||||
base += `/[${escapeArbitrary(variant.modifier.value)}]`
|
||||
} else if (variant.modifier.kind === 'named') {
|
||||
base += `/${variant.modifier.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
function escapeArbitrary(input: string) {
|
||||
return input
|
||||
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
|
||||
.replaceAll(' ', '_') // Replace spaces with underscores
|
||||
}
|
||||
|
||||
export function replaceCandidateInContent(
|
||||
content: string,
|
||||
replacement: string,
|
||||
startByte: number,
|
||||
endByte: number,
|
||||
) {
|
||||
return stringByteSlice(content, 0, startByte) + replacement + stringByteSlice(content, endByte)
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import dedent from 'dedent'
|
||||
import { expect, test } from 'vitest'
|
||||
import migrate from '../migrate'
|
||||
import { migrateImportant } from './migrate-important'
|
||||
|
||||
let html = dedent
|
||||
|
||||
test('applies the migration', async () => {
|
||||
let content = html`
|
||||
<div class="bg-blue-500 !flex min-[calc(1000px+12em)]:!flex">
|
||||
<button class="md:!block">My button</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(`
|
||||
"<div class="bg-blue-500 flex! min-[calc(1000px_+_12em)]:flex!">
|
||||
<button class="md:block!">My button</button>
|
||||
</div>"
|
||||
`)
|
||||
})
|
||||
|
||||
test('does not migrate if the exclamation mark is already at the end', async () => {
|
||||
let content = html` <div class="min-[calc(1000px+12em)]:flex!"></div> `
|
||||
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(`
|
||||
"<div class="min-[calc(1000px+12em)]:flex!"></div>"
|
||||
`)
|
||||
})
|
||||
@ -0,0 +1,23 @@
|
||||
import type { Candidate } from '../../../../tailwindcss/src/candidate'
|
||||
|
||||
// In v3 the important modifier `!` sits in front of the utility itself, not
|
||||
// before any of the variants. In v4, we want it to be at the end of the utility
|
||||
// so that it's always in the same location regardless of whether you used
|
||||
// variants or not.
|
||||
//
|
||||
// So this:
|
||||
//
|
||||
// !flex md:!block
|
||||
//
|
||||
// Should turn into:
|
||||
//
|
||||
// flex! md:block!
|
||||
export function migrateImportant(candidate: Candidate): Candidate | null {
|
||||
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
|
||||
// The printCandidate function will already put the exclamation mark in the
|
||||
// right place, so we just need to mark this candidate as requiring a
|
||||
// migration.
|
||||
return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
44
packages/@tailwindcss-upgrade/src/template/migrate.ts
Normal file
44
packages/@tailwindcss-upgrade/src/template/migrate.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Candidate } from '../../../tailwindcss/src/candidate'
|
||||
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
|
||||
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
|
||||
import { migrateImportant } from './codemods/migrate-important'
|
||||
|
||||
export type Migration = (candidate: Candidate) => Candidate | null
|
||||
|
||||
export default async function migrateContents(
|
||||
designSystem: DesignSystem,
|
||||
contents: string,
|
||||
migrations: Migration[] = [migrateImportant],
|
||||
): Promise<string> {
|
||||
let candidates = await extractCandidates(designSystem, contents)
|
||||
|
||||
// Sort candidates by starting position desc
|
||||
candidates.sort((a, z) => z.start - a.start)
|
||||
|
||||
let output = contents
|
||||
for (let { candidate, start, end } of candidates) {
|
||||
let needsMigration = false
|
||||
for (let migration of migrations) {
|
||||
let migrated = migration(candidate)
|
||||
if (migrated) {
|
||||
candidate = migrated
|
||||
needsMigration = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
output = replaceCandidateInContent(output, printCandidate(candidate), start, end)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export async function migrate(designSystem: DesignSystem, file: string) {
|
||||
let fullPath = path.resolve(process.cwd(), file)
|
||||
let contents = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
await fs.writeFile(fullPath, await migrateContents(designSystem, contents))
|
||||
}
|
||||
34
packages/@tailwindcss-upgrade/src/template/parseConfig.ts
Normal file
34
packages/@tailwindcss-upgrade/src/template/parseConfig.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node'
|
||||
import path from 'node:path'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
export async function parseConfig(
|
||||
configPath: string,
|
||||
options: { base: string },
|
||||
): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> {
|
||||
// We create a relative path from the current file to the config file. This is
|
||||
// required so that the base for Tailwind CSS can bet inside the
|
||||
// @tailwindcss-upgrade package and we can require `tailwindcss` properly.
|
||||
let fullConfigPath = path.resolve(options.base, configPath)
|
||||
let fullFilePath = path.resolve(__dirname)
|
||||
let relative = path.relative(fullFilePath, fullConfigPath)
|
||||
// If the path points to a file in the same directory, `path.relative` will
|
||||
// remove the leading `./` and we need to add it back in order to still
|
||||
// consider the path relative
|
||||
if (!relative.startsWith('.')) {
|
||||
relative = './' + relative
|
||||
}
|
||||
|
||||
let input = `@import 'tailwindcss';\n@config './${relative}'`
|
||||
|
||||
let [compiler, designSystem] = await Promise.all([
|
||||
compile(input, { base: __dirname, onDependency: () => {} }),
|
||||
__unstable__loadDesignSystem(input, { base: __dirname }),
|
||||
])
|
||||
return { designSystem, globs: compiler.globs }
|
||||
}
|
||||
@ -189,7 +189,7 @@ export type Candidate =
|
||||
* E.g.:
|
||||
*
|
||||
* - `underline`
|
||||
* - `flex`
|
||||
* - `box-border`
|
||||
*/
|
||||
| {
|
||||
kind: 'static'
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -270,6 +270,12 @@ importers:
|
||||
|
||||
packages/@tailwindcss-upgrade:
|
||||
dependencies:
|
||||
'@tailwindcss/node':
|
||||
specifier: workspace:^
|
||||
version: link:../@tailwindcss-node
|
||||
'@tailwindcss/oxide':
|
||||
specifier: workspace:^
|
||||
version: link:../../crates/node
|
||||
enhanced-resolve:
|
||||
specifier: ^5.17.1
|
||||
version: 5.17.1
|
||||
@ -294,6 +300,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
string-byte-slice:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
tailwindcss:
|
||||
specifier: workspace:^
|
||||
version: link:../tailwindcss
|
||||
@ -2781,6 +2790,10 @@ packages:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
string-byte-slice@3.0.0:
|
||||
resolution: {integrity: sha512-KqTTvThKPDgBPr9jI2cOdO04tJ+upcADk4j4zmcBNmG6Bqstsq1x1Z3xvJAPqRQgPE8yocXNLVZuCfYlv4+PTg==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@ -5554,6 +5567,8 @@ snapshots:
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string-byte-slice@3.0.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user