Skip to content

Commit 732147a

Browse files
philipp-spiessRobinMalfaitthecrypticace
authored
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 <[email protected]> Co-authored-by: Jordan Pittman <[email protected]>
1 parent a144360 commit 732147a

File tree

16 files changed

+758
-66
lines changed

16 files changed

+758
-66
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434))
3333
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504))
3434
- _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))
35+
- _Experimental_: Add template codemods for migrating important utilities (e.g. `!flex` to `flex!`) ([#14502](https://github.com/tailwindlabs/tailwindcss/pull/14502))
3536

3637
### Fixed
3738

crates/node/src/lib.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ pub struct Scanner {
8282
scanner: tailwindcss_oxide::Scanner,
8383
}
8484

85+
#[derive(Debug, Clone)]
86+
#[napi(object)]
87+
pub struct CandidateWithPosition {
88+
/// The candidate string
89+
pub candidate: String,
90+
91+
/// The position of the candidate inside the content file
92+
pub position: i64,
93+
}
94+
8595
#[napi]
8696
impl Scanner {
8797
#[napi(constructor)]
@@ -108,6 +118,22 @@ impl Scanner {
108118
.scan_content(input.into_iter().map(Into::into).collect())
109119
}
110120

121+
#[napi]
122+
pub fn get_candidates_with_positions(
123+
&mut self,
124+
input: ChangedContent,
125+
) -> Vec<CandidateWithPosition> {
126+
self
127+
.scanner
128+
.get_candidates_with_positions(input.into())
129+
.into_iter()
130+
.map(|(candidate, position)| CandidateWithPosition {
131+
candidate,
132+
position: position as i64,
133+
})
134+
.collect()
135+
}
136+
111137
#[napi(getter)]
112138
pub fn files(&mut self) -> Vec<String> {
113139
self.scanner.get_files()

crates/oxide/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ impl Scanner {
124124
new_candidates
125125
}
126126

127+
#[tracing::instrument(skip_all)]
128+
pub fn get_candidates_with_positions(
129+
&mut self,
130+
changed_content: ChangedContent,
131+
) -> Vec<(String, usize)> {
132+
self.prepare();
133+
134+
let content = read_changed_content(changed_content).unwrap_or_default();
135+
let extractor = Extractor::with_positions(&content[..], Default::default());
136+
137+
let candidates: Vec<(String, usize)> = extractor
138+
.into_iter()
139+
.map(|(s, i)| {
140+
// SAFETY: When we parsed the candidates, we already guaranteed that the byte slices
141+
// are valid, therefore we don't have to re-check here when we want to convert it back
142+
// to a string.
143+
unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }
144+
})
145+
.collect();
146+
candidates
147+
}
148+
127149
#[tracing::instrument(skip_all)]
128150
pub fn get_files(&mut self) -> Vec<String> {
129151
self.prepare();

crates/oxide/src/parser.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ impl<'a> Extractor<'a> {
8282

8383
candidates
8484
}
85+
86+
pub fn with_positions(input: &'a [u8], opts: ExtractorOptions) -> Vec<(&'a [u8], usize)> {
87+
let mut result = Vec::new();
88+
let extractor = Self::new(input, opts).flatten();
89+
for item in extractor {
90+
// Since the items are slices of the input buffer, we can calculate the start index
91+
// by doing some pointer arithmetics.
92+
let start_index = item.as_ptr() as usize - input.as_ptr() as usize;
93+
result.push((item, start_index));
94+
}
95+
result
96+
}
8597
}
8698

8799
impl<'a> Extractor<'a> {

integrations/cli/upgrade.test.ts renamed to integrations/upgrade/index.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,47 @@
1-
import { css, json, test } from '../utils'
1+
import { css, html, js, json, test } from '../utils'
2+
3+
test(
4+
`upgrades a v3 project to v4`,
5+
{
6+
fs: {
7+
'package.json': json`
8+
{
9+
"dependencies": {
10+
"@tailwindcss/upgrade": "workspace:^"
11+
}
12+
}
13+
`,
14+
'tailwind.config.js': js`
15+
/** @type {import('tailwindcss').Config} */
16+
module.exports = {
17+
content: ['./src/**/*.{html,js}'],
18+
}
19+
`,
20+
'src/index.html': html`
21+
<h1>🤠👋</h1>
22+
<div class="!flex sm:!block"></div>
23+
`,
24+
'src/input.css': css`
25+
@tailwind base;
26+
@tailwind components;
27+
@tailwind utilities;
28+
`,
29+
},
30+
},
31+
async ({ exec, fs }) => {
32+
await exec('npx @tailwindcss/upgrade -c tailwind.config.js')
33+
34+
await fs.expectFileToContain(
35+
'src/index.html',
36+
html`
37+
<h1>🤠👋</h1>
38+
<div class="flex! sm:block!"></div>
39+
`,
40+
)
41+
42+
await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`)
43+
},
44+
)
245

346
test(
447
'migrate @apply',

packages/@tailwindcss-node/src/compile.ts

Lines changed: 68 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import fs from 'node:fs'
44
import fsPromises from 'node:fs/promises'
55
import path, { dirname, extname } from 'node:path'
66
import { pathToFileURL } from 'node:url'
7-
import { compile as _compile } from 'tailwindcss'
7+
import {
8+
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
9+
compile as _compile,
10+
} from 'tailwindcss'
811
import { getModuleDependencies } from './get-module-dependencies'
912

1013
export async function compile(
@@ -14,59 +17,78 @@ export async function compile(
1417
return await _compile(css, {
1518
base,
1619
async loadModule(id, base) {
17-
if (id[0] !== '.') {
18-
let resolvedPath = await resolveJsId(id, base)
19-
if (!resolvedPath) {
20-
throw new Error(`Could not resolve '${id}' from '${base}'`)
21-
}
22-
23-
let module = await importModule(pathToFileURL(resolvedPath).href)
24-
return {
25-
base: dirname(resolvedPath),
26-
module: module.default ?? module,
27-
}
28-
}
20+
return loadModule(id, base, onDependency)
21+
},
22+
async loadStylesheet(id, base) {
23+
return loadStylesheet(id, base, onDependency)
24+
},
25+
})
26+
}
2927

30-
let resolvedPath = await resolveJsId(id, base)
31-
if (!resolvedPath) {
32-
throw new Error(`Could not resolve '${id}' from '${base}'`)
33-
}
34-
let [module, moduleDependencies] = await Promise.all([
35-
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
36-
getModuleDependencies(resolvedPath),
37-
])
38-
39-
onDependency(resolvedPath)
40-
for (let file of moduleDependencies) {
41-
onDependency(file)
42-
}
43-
return {
44-
base: dirname(resolvedPath),
45-
module: module.default ?? module,
46-
}
28+
export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) {
29+
return ___unstable__loadDesignSystem(css, {
30+
base,
31+
async loadModule(id, base) {
32+
return loadModule(id, base, () => {})
33+
},
34+
async loadStylesheet(id, base) {
35+
return loadStylesheet(id, base, () => {})
4736
},
37+
})
38+
}
4839

49-
async loadStylesheet(id, basedir) {
50-
let resolvedPath = await resolveCssId(id, basedir)
51-
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${basedir}'`)
52-
53-
if (typeof globalThis.__tw_readFile === 'function') {
54-
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
55-
if (file) {
56-
return {
57-
base: path.dirname(resolvedPath),
58-
content: file,
59-
}
60-
}
61-
}
40+
async function loadModule(id: string, base: string, onDependency: (path: string) => void) {
41+
if (id[0] !== '.') {
42+
let resolvedPath = await resolveJsId(id, base)
43+
if (!resolvedPath) {
44+
throw new Error(`Could not resolve '${id}' from '${base}'`)
45+
}
6246

63-
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
47+
let module = await importModule(pathToFileURL(resolvedPath).href)
48+
return {
49+
base: dirname(resolvedPath),
50+
module: module.default ?? module,
51+
}
52+
}
53+
54+
let resolvedPath = await resolveJsId(id, base)
55+
if (!resolvedPath) {
56+
throw new Error(`Could not resolve '${id}' from '${base}'`)
57+
}
58+
let [module, moduleDependencies] = await Promise.all([
59+
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
60+
getModuleDependencies(resolvedPath),
61+
])
62+
63+
onDependency(resolvedPath)
64+
for (let file of moduleDependencies) {
65+
onDependency(file)
66+
}
67+
return {
68+
base: dirname(resolvedPath),
69+
module: module.default ?? module,
70+
}
71+
}
72+
73+
async function loadStylesheet(id: string, base: string, onDependency: (path: string) => void) {
74+
let resolvedPath = await resolveCssId(id, base)
75+
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`)
76+
77+
if (typeof globalThis.__tw_readFile === 'function') {
78+
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
79+
if (file) {
6480
return {
6581
base: path.dirname(resolvedPath),
6682
content: file,
6783
}
68-
},
69-
})
84+
}
85+
}
86+
87+
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
88+
return {
89+
base: path.dirname(resolvedPath),
90+
content: file,
91+
}
7092
}
7193

7294
// Attempts to import the module using the native `import()` function. If this

packages/@tailwindcss-upgrade/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"access": "public"
2828
},
2929
"dependencies": {
30+
"@tailwindcss/node": "workspace:^",
31+
"@tailwindcss/oxide": "workspace:^",
3032
"enhanced-resolve": "^5.17.1",
3133
"globby": "^14.0.2",
3234
"mri": "^1.2.0",
@@ -35,6 +37,7 @@
3537
"postcss-import": "^16.1.0",
3638
"postcss-selector-parser": "^6.1.2",
3739
"prettier": "^3.3.3",
40+
"string-byte-slice": "^3.0.0",
3841
"tailwindcss": "workspace:^"
3942
},
4043
"devDependencies": {

0 commit comments

Comments
 (0)