diff --git a/CHANGELOG.md b/CHANGELOG.md index 172e95fdf7fe..472748e0ea4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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 diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 7e8717ec400c..c494f008ae1a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -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 { + 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 { self.scanner.get_files() diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs index 58719dc70bb5..79c2bcb9e59a 100644 --- a/crates/oxide/src/lib.rs +++ b/crates/oxide/src/lib.rs @@ -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 { self.prepare(); diff --git a/crates/oxide/src/parser.rs b/crates/oxide/src/parser.rs index ad612f9020b2..cf5f42fb3e33 100644 --- a/crates/oxide/src/parser.rs +++ b/crates/oxide/src/parser.rs @@ -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> { diff --git a/integrations/cli/upgrade.test.ts b/integrations/upgrade/index.test.ts similarity index 74% rename from integrations/cli/upgrade.test.ts rename to integrations/upgrade/index.test.ts index 9629a685eb1e..aa2e344eeb9c 100644 --- a/integrations/cli/upgrade.test.ts +++ b/integrations/upgrade/index.test.ts @@ -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` +

🤠👋

+
+ `, + '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` +

🤠👋

+
+ `, + ) + + await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`) + }, +) test( 'migrate @apply', diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts index 02332d578262..0f2cd712856d 100644 --- a/packages/@tailwindcss-node/src/compile.ts +++ b/packages/@tailwindcss-node/src/compile.ts @@ -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, - } - } + return loadModule(id, base, onDependency) + }, + async loadStylesheet(id, base) { + return loadStylesheet(id, base, onDependency) + }, + }) +} - 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, - } +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, () => {}) }, + }) +} - async loadStylesheet(id, basedir) { - let resolvedPath = await resolveCssId(id, basedir) - if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${basedir}'`) - - 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 diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index bfb04c9c16e1..87539e92c4cf 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -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": { diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index d3243a199968..f84be145164e 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -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)) + 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) + } + } + + if (parsedConfig) { + // Template migrations + + info('Migrating templates using the provided configuration file.') + + let set = new Set() + 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() - // 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…', - ) + // Migrate each file + await Promise.allSettled(files.map((file) => migrateTemplate(parsedConfig.designSystem, file))) - files = await globby(['**/*.css'], { - absolute: true, - gitignore: true, - }) + success('Template migration complete.') } - // Ensure we are only dealing with CSS files - files = files.filter((file) => file.endsWith('.css')) + { + // Stylesheet migrations - // Migrate each file - await Promise.allSettled(files.map((file) => migrate(file))) + // 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.') } } diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts new file mode 100644 index 000000000000..634cbe5e92e4 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -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` +
+ +
+ ` + + 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` +

🤠👋

+
+ ` + + 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(` + " +

🤠👋

+
+ " + `) +}) + +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]) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts new file mode 100644 index 000000000000..19b61d996217 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -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) +} diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts new file mode 100644 index 000000000000..0755dd41a808 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -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` +
+ +
+ ` + + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(` + "
+ +
" + `) +}) + +test('does not migrate if the exclamation mark is already at the end', async () => { + let content = html`
` + + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(` + "
" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts new file mode 100644 index 000000000000..44a23a1cb4b7 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts @@ -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 +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts new file mode 100644 index 000000000000..c46be2afa1a4 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -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 { + 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)) +} diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts new file mode 100644 index 000000000000..12c54014e494 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -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 } +} diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index ea11e2fb7ab8..178fabdfa555 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -189,7 +189,7 @@ export type Candidate = * E.g.: * * - `underline` - * - `flex` + * - `box-border` */ | { kind: 'static' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb0394c2db4..fd2ee2ce22e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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