From 4d5d0238e6758fd82139774f4668801a1a815a9a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 13:48:32 +0200 Subject: [PATCH 01/17] WIP --- crates/node/src/lib.rs | 26 +++ crates/oxide/src/lib.rs | 22 +++ crates/oxide/src/parser.rs | 10 ++ package.json | 2 +- packages/@tailwindcss-node/src/compile.ts | 114 ++++++++----- packages/@tailwindcss-upgrade/package.json | 4 +- .../src/template/candidates.test.ts | 158 ++++++++++++++++++ .../src/template/candidates.ts | 77 +++++++++ packages/tailwindcss/src/candidate.ts | 2 +- pnpm-lock.yaml | 6 + 10 files changed, 372 insertions(+), 49 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/candidates.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/candidates.ts diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 7e8717ec400c..cb8f091c50d5 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 { + /// Base path of the glob + pub candidate: String, + + /// Glob pattern + pub position: f64, +} + #[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 f64, + }) + .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..0e478e5a06a7 100644 --- a/crates/oxide/src/parser.rs +++ b/crates/oxide/src/parser.rs @@ -82,6 +82,16 @@ 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 { + 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/package.json b/package.json index e37447099703..ff5ff7892075 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "scripts": { "format": "prettier --write .", "lint": "prettier --check . && turbo lint", - "build": "turbo build --filter=!./playgrounds/*", + "build": "turbo build --filter=!./playgrounds/* --force", "postbuild": "node ./scripts/pack-packages.mjs", "dev": "turbo dev --filter=!./playgrounds/*", "test": "cargo test && vitest run", 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..b920e1c9dd66 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -35,7 +35,9 @@ "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", - "tailwindcss": "workspace:^" + "tailwindcss": "workspace:^", + "@tailwindcss/oxide": "workspace:^", + "@tailwindcss/node": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", 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..f080e7af0139 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -0,0 +1,158 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test } from 'vitest' +import { parseCandidate } from '../../../tailwindcss/src/candidate' +import { extractCandidates, toString } from './candidates' + +let html = String.raw + +test.skip('extracts candidates with positions from a template', () => { + let content = html` +
+ +
+ ` + + expect(extractCandidates(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, + }, + ] + `) +}) + +describe('toString()', () => { + test.each([ + // 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'], + ])('%s', async (candidate: string, result: string) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + let candidates = parseCandidate(candidate, designSystem) + + // TODO: This seems unexpected? + // 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(toString)) + + 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..a66245978b30 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -0,0 +1,77 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { Scanner } from '@tailwindcss/oxide' +// This file uses private APIs to work with candidates. +// TODO: Should we export this in the public package so we have the same +// version as the tailwindcss package? +import { + parseCandidate, + type Candidate, + type CandidateModifier, +} from '../../../tailwindcss/src/candidate' + +let css = String.raw + +export async function extractCandidates( + content: string, +): Promise<{ candidate: Candidate; start: number; end: number }[]> { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { base: __dirname }, + ) + 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 parseCandidate(rawCandidate, designSystem)) { + candidates.push({ candidate, start, end: start + rawCandidate.length }) + } + } + return candidates +} + +export function toString(candidate: Candidate): string { + let variants = '' + let important = candidate.important ? '!' : '' + + switch (candidate.kind) { + case 'arbitrary': { + return `${variants}[${candidate.property}:${candidate.value}]${formatModifier( + candidate.modifier, + )}${important}` + } + case 'static': { + return `${formatNegative(candidate.negative)}${variants}${candidate.root}${important}` + } + case 'functional': { + let value = + candidate.value === null + ? '' + : candidate.value.kind === 'named' + ? `-${candidate.value.value}` + : `-[${candidate.value.value}]` + + return `${formatNegative(candidate.negative)}${variants}${candidate.root}${value}${formatModifier( + candidate.modifier, + )}${important}` + } + } +} + +function formatModifier(modifier: CandidateModifier | null): string { + if (modifier === null) { + return '' + } + switch (modifier.kind) { + case 'arbitrary': + return `/[${modifier.value}]` + case 'named': + return `/${modifier.value}` + } +} + +function formatNegative(negative: boolean): string { + return negative ? '-' : '' +} 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..8917e64dab7a 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 From c767e020cc03568eda1ccb15a204f8b58ec82bf5 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:15:37 +0200 Subject: [PATCH 02/17] Add important migration --- .../src/template/candidates.test.ts | 91 +++++++---- .../src/template/candidates.ts | 144 +++++++++++++----- .../codemods/migrate-important.test.ts | 20 +++ .../template/codemods/migrate-important.ts | 23 +++ .../src/template/migrate.ts | 30 ++++ 5 files changed, 242 insertions(+), 66 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/migrate.ts diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index f080e7af0139..b8f1b846cc83 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,11 +1,11 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' import { parseCandidate } from '../../../tailwindcss/src/candidate' -import { extractCandidates, toString } from './candidates' +import { extractCandidates, printCandidate } from './candidates' let html = String.raw -test.skip('extracts candidates with positions from a template', () => { +test('extracts candidates with positions from a template', () => { let content = html`
@@ -114,44 +114,75 @@ test.skip('extracts candidates with positions from a template', () => { `) }) -describe('toString()', () => { - test.each([ - // 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]!'], +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:', - // Static candidates - ['box-border', 'box-border'], - ['underline!', 'underline!'], - ['!underline', 'underline!'], - ['-inset-full', '-inset-full'], + 'min-[10px]:', + // TODO: This currently expands `calc(1000px+12em)` to `calc(1000px_+_12em)` + 'min-[calc(1000px_+_12em)]:', - // 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'], - ])('%s', async (candidate: string, result: string) => { + '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 = parseCandidate(candidate, designSystem) - // TODO: This seems unexpected? // 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(toString)) + // TODO: This seems unexpected? + 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 index a66245978b30..a246b5ca4678 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -3,11 +3,7 @@ import { Scanner } from '@tailwindcss/oxide' // This file uses private APIs to work with candidates. // TODO: Should we export this in the public package so we have the same // version as the tailwindcss package? -import { - parseCandidate, - type Candidate, - type CandidateModifier, -} from '../../../tailwindcss/src/candidate' +import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' let css = String.raw @@ -32,46 +28,122 @@ export async function extractCandidates( return candidates } -export function toString(candidate: Candidate): string { - let variants = '' - let important = candidate.important ? '!' : '' +export function printCandidate(candidate: Candidate | null) { + if (candidate === null) return 'null' + let parts: string[] = [] - switch (candidate.kind) { - case 'arbitrary': { - return `${variants}[${candidate.property}:${candidate.value}]${formatModifier( - candidate.modifier, - )}${important}` + 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 += '-' } - case 'static': { - return `${formatNegative(candidate.negative)}${variants}${candidate.root}${important}` + } + + // 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}` + } } - case 'functional': { - let value = - candidate.value === null - ? '' - : candidate.value.kind === 'named' - ? `-${candidate.value.value}` - : `-[${candidate.value.value}]` - - return `${formatNegative(candidate.negative)}${variants}${candidate.root}${value}${formatModifier( - candidate.modifier, - )}${important}` + } + + // 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 formatModifier(modifier: CandidateModifier | null): string { - if (modifier === null) { - return '' +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)}]` } - switch (modifier.kind) { - case 'arbitrary': - return `/[${modifier.value}]` - case 'named': - return `/${modifier.value}` + + let base: string = '' + + // Handle functional variants + if (variant.kind === 'functional') { + if (variant.value) { + if (variant.value.kind === 'arbitrary') { + base += `${variant.root}-[${escapeArbitrary(variant.value.value)}]` + } else if (variant.value.kind === 'named') { + base += `${variant.root}-${variant.value.value}` + } + } else { + base += variant.root + } } + + // 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 formatNegative(negative: boolean): string { - return negative ? '-' : '' +function escapeArbitrary(input: string) { + return input + .replaceAll(String.raw`_`, String.raw`\_`) // Escape underscores to keep them as-is + .replaceAll(String.raw` `, String.raw`_`) // Replace spaces with underscores } 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..1f88b8fa1af7 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -0,0 +1,20 @@ +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', () => { + let content = html` +
+ +
+ ` + + expect(migrate(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..703481aa4b2c --- /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) { + // 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..f61bde6a7fb6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -0,0 +1,30 @@ +import type { Candidate } from '../../../tailwindcss/src/candidate' +import { extractCandidates, printCandidate } from './candidates' + +export type Migration = (candidate: Candidate) => Candidate | null + +export default async function migrate(input: string, migrations: Migration[]): Promise { + let candidates = await extractCandidates(input) + + // Sort candidates by starting position desc + candidates.sort((a, z) => z.start - a.start) + + let output = input + 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 + break + } + } + + if (needsMigration) { + output = output.slice(0, start) + printCandidate(candidate) + output.slice(end) + } + } + + return output +} From a132a08fa40c6f27c066f9d8bf80edeb343bc9a1 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:46:39 +0200 Subject: [PATCH 03/17] Add migration scaffolding --- .../src/fixtures/src/index.html | 8 +++++++ .../src/fixtures/tailwind.config.js | 4 ++++ packages/@tailwindcss-upgrade/src/index.ts | 18 +++++++++++++++- .../src/template/candidates.test.ts | 8 +++++-- .../src/template/candidates.ts | 11 ++-------- .../codemods/migrate-important.test.ts | 9 ++++++-- .../src/template/migrate.ts | 21 ++++++++++++++++--- .../src/template/parseConfig.ts | 20 ++++++++++++++++++ 8 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/index.html create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js create mode 100644 packages/@tailwindcss-upgrade/src/template/parseConfig.ts diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html new file mode 100644 index 000000000000..db4a752da30e --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html @@ -0,0 +1,8 @@ + + + My Tailwind CSS Upgrade Example + + +
+ + diff --git a/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js b/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js new file mode 100644 index 000000000000..a3687ebedc54 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{html,js}'], +} diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index d3243a199968..1fac7c6f75f6 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -2,13 +2,16 @@ 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 { 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,13 +40,26 @@ async function run() { } } + let designSystem: DesignSystem | null = null + let paths: string[] = [] + if (flags['--config']) { + try { + designSystem = await parseConfig(flags['--config'], { base: process.cwd() }) + } catch (e: any) { + error(`Failed to parse the configuration file: ${e.message}`) + process.exit(1) + } + } + + // console.log(designSystem) + // 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…', + 'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…', ) files = await globby(['**/*.css'], { diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index b8f1b846cc83..76e2fbb45921 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -5,14 +5,18 @@ import { extractCandidates, printCandidate } from './candidates' let html = String.raw -test('extracts candidates with positions from a template', () => { +test('extracts candidates with positions from a template', async () => { let content = html`
` - expect(extractCandidates(content)).resolves.toMatchInlineSnapshot(` + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(extractCandidates(designSystem, content)).resolves.toMatchInlineSnapshot(` [ { "candidate": { diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index a246b5ca4678..5c276e5b7bb9 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,21 +1,14 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { Scanner } from '@tailwindcss/oxide' // This file uses private APIs to work with candidates. // TODO: Should we export this in the public package so we have the same // version as the tailwindcss package? import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' - -let css = String.raw +import type { DesignSystem } from '../../../tailwindcss/src/design-system' export async function extractCandidates( + designSystem: DesignSystem, content: string, ): Promise<{ candidate: Candidate; start: number; end: number }[]> { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { base: __dirname }, - ) let scanner = new Scanner({}) let result = scanner.getCandidatesWithPositions({ content, extension: 'html' }) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts index 1f88b8fa1af7..40da40d590f9 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -1,3 +1,4 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' import { expect, test } from 'vitest' import migrate from '../migrate' @@ -5,14 +6,18 @@ import { migrateImportant } from './migrate-important' let html = dedent -test('applies the migration', () => { +test('applies the migration', async () => { let content = html`
` - expect(migrate(content, [migrateImportant])).resolves.toMatchInlineSnapshot(` + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(` "
" diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index f61bde6a7fb6..80e708c878c5 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -1,15 +1,23 @@ +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 } from './candidates' +import { migrateImportant } from './codemods/migrate-important' export type Migration = (candidate: Candidate) => Candidate | null -export default async function migrate(input: string, migrations: Migration[]): Promise { - let candidates = await extractCandidates(input) +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 = input + let output = contents for (let { candidate, start, end } of candidates) { let needsMigration = false for (let migration of migrations) { @@ -28,3 +36,10 @@ export default async function migrate(input: string, migrations: Migration[]): P 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..6035ac3fe5d6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -0,0 +1,20 @@ +import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node' +import dedent from 'dedent' +import type { DesignSystem } from '../../../tailwindcss/src/design-system' + +let css = dedent +export async function parseConfig( + path: string, + options: { base: string }, +): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { + let input = css` + @import 'tailwindcss'; + @config './${path}'; + ` + + let [compiler, designSystem] = await Promise.all([ + compile(input, { ...options, onDependency: () => {} }), + __unstable__loadDesignSystem(input, options), + ]) + return { designSystem, globs: compiler.globs } +} From 141cd39d7d9ff9c52e3326b2054c6f58e3645a7c Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:55:56 +0200 Subject: [PATCH 04/17] Migrate stylesheets and templates --- .../src/fixtures/src/index.html | 2 +- .../src/fixtures/src/input.css | 3 + packages/@tailwindcss-upgrade/src/index.ts | 78 +++++++++++++------ 3 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/input.css diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html index db4a752da30e..0b7dfc0ddc02 100644 --- a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html @@ -3,6 +3,6 @@ My Tailwind CSS Upgrade Example -
+
diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/input.css b/packages/@tailwindcss-upgrade/src/fixtures/src/input.css new file mode 100644 index 000000000000..b5c61c956711 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 1fac7c6f75f6..f84be145164e 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -4,7 +4,8 @@ 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' @@ -40,45 +41,78 @@ async function run() { } } - let designSystem: DesignSystem | null = null - let paths: string[] = [] + let parsedConfig: { + designSystem: DesignSystem + globs: { pattern: string; base: string }[] + } | null = null if (flags['--config']) { try { - designSystem = await parseConfig(flags['--config'], { base: process.cwd() }) + parsedConfig = await parseConfig(flags['--config'], { base: process.cwd() }) } catch (e: any) { error(`Failed to parse the configuration file: ${e.message}`) process.exit(1) } } - // console.log(designSystem) + if (parsedConfig) { + // Template migrations - // Use provided files - let files = flags._.map((file) => path.resolve(process.cwd(), file)) + info('Migrating templates using the provided configuration 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…', - ) + let set = new Set() + for (let { pattern, base } of parsedConfig.globs) { + let files = await globby([pattern], { + absolute: true, + gitignore: true, + cwd: base, + }) - files = await globby(['**/*.css'], { - absolute: true, - gitignore: true, - }) + 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.') } - // 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.') } } From 8846fb46f2e2f4bcafe919d850a130c3f9ed6501 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 16:28:55 +0200 Subject: [PATCH 05/17] Use parseCandidate from the DesignSystem --- .../@tailwindcss-upgrade/src/template/candidates.test.ts | 3 +-- packages/@tailwindcss-upgrade/src/template/candidates.ts | 7 ++----- packages/@tailwindcss-upgrade/src/template/parseConfig.ts | 1 + 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index 76e2fbb45921..e9acc519b1b6 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,6 +1,5 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' -import { parseCandidate } from '../../../tailwindcss/src/candidate' import { extractCandidates, printCandidate } from './candidates' let html = String.raw @@ -181,7 +180,7 @@ describe('toString()', () => { base: __dirname, }) - let candidates = parseCandidate(candidate, designSystem) + 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. diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index 5c276e5b7bb9..918f50e4a2a7 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,8 +1,5 @@ import { Scanner } from '@tailwindcss/oxide' -// This file uses private APIs to work with candidates. -// TODO: Should we export this in the public package so we have the same -// version as the tailwindcss package? -import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' +import { type Candidate, type Variant } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' export async function extractCandidates( @@ -14,7 +11,7 @@ export async function extractCandidates( let candidates: { candidate: Candidate; start: number; end: number }[] = [] for (let { candidate: rawCandidate, position: start } of result) { - for (let candidate of parseCandidate(rawCandidate, designSystem)) { + for (let candidate of designSystem.parseCandidate(rawCandidate)) { candidates.push({ candidate, start, end: start + rawCandidate.length }) } } diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts index 6035ac3fe5d6..ac32e7f359dc 100644 --- a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -7,6 +7,7 @@ export async function parseConfig( path: string, options: { base: string }, ): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { + // TODO: base path needs to be resolved to v4 let input = css` @import 'tailwindcss'; @config './${path}'; From c8fc77b4590d22acd7a88bad16bcf221829be36a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 11:30:12 +0200 Subject: [PATCH 06/17] Use i64 over f64 for pos --- crates/node/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index cb8f091c50d5..5c9baa67bc0d 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -85,11 +85,11 @@ pub struct Scanner { #[derive(Debug, Clone)] #[napi(object)] pub struct CandidateWithPosition { - /// Base path of the glob + // The candidate string pub candidate: String, - /// Glob pattern - pub position: f64, + // The position of the candidate inside the content file + pub position: i64, } #[napi] @@ -129,7 +129,7 @@ impl Scanner { .into_iter() .map(|(candidate, position)| CandidateWithPosition { candidate, - position: position as f64, + position: position as i64, }) .collect() } From da60fb87019fa118a6525f69a4b6b4c89c149bb2 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 11:46:52 +0200 Subject: [PATCH 07/17] Fix slice issues with unicode surrogates --- packages/@tailwindcss-upgrade/package.json | 7 +++--- .../src/template/candidates.test.ts | 23 ++++++++++++++++++- .../src/template/candidates.ts | 10 ++++++++ .../src/template/migrate.ts | 4 ++-- pnpm-lock.yaml | 9 ++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index b920e1c9dd66..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,9 +37,8 @@ "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", - "tailwindcss": "workspace:^", - "@tailwindcss/oxide": "workspace:^", - "@tailwindcss/node": "workspace:^" + "string-byte-slice": "^3.0.0", + "tailwindcss": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index e9acc519b1b6..bc754ab6f6e2 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,6 +1,6 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' -import { extractCandidates, printCandidate } from './candidates' +import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates' let html = String.raw @@ -117,6 +117,27 @@ test('extracts candidates with positions from a template', async () => { `) }) +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]'], diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index 918f50e4a2a7..be850fc65ee5 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,4 +1,5 @@ import { Scanner } from '@tailwindcss/oxide' +import stringByteSlice from 'string-byte-slice' import { type Candidate, type Variant } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' @@ -137,3 +138,12 @@ function escapeArbitrary(input: string) { .replaceAll(String.raw`_`, String.raw`\_`) // Escape underscores to keep them as-is .replaceAll(String.raw` `, String.raw`_`) // 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/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 80e708c878c5..2decefc76781 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -2,7 +2,7 @@ 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 } from './candidates' +import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates' import { migrateImportant } from './codemods/migrate-important' export type Migration = (candidate: Candidate) => Candidate | null @@ -30,7 +30,7 @@ export default async function migrateContents( } if (needsMigration) { - output = output.slice(0, start) + printCandidate(candidate) + output.slice(end) + output = replaceCandidateInContent(output, printCandidate(candidate), start, end) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8917e64dab7a..fd2ee2ce22e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,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 @@ -2787,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'} @@ -5560,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 From 7c26a20de0a85ab18dd0b52cab27445f7b3f70bc Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 12:12:34 +0200 Subject: [PATCH 08/17] Add docs and remove --force --- crates/oxide/src/parser.rs | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/oxide/src/parser.rs b/crates/oxide/src/parser.rs index 0e478e5a06a7..cf5f42fb3e33 100644 --- a/crates/oxide/src/parser.rs +++ b/crates/oxide/src/parser.rs @@ -87,6 +87,8 @@ impl<'a> Extractor<'a> { 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)); } diff --git a/package.json b/package.json index ff5ff7892075..e37447099703 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "scripts": { "format": "prettier --write .", "lint": "prettier --check . && turbo lint", - "build": "turbo build --filter=!./playgrounds/* --force", + "build": "turbo build --filter=!./playgrounds/*", "postbuild": "node ./scripts/pack-packages.mjs", "dev": "turbo dev --filter=!./playgrounds/*", "test": "cargo test && vitest run", From 0d4dfe0318f97fe820aab85a4af22f61ce15eed3 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 12:20:32 +0200 Subject: [PATCH 09/17] Move upgrade tests to dedicated directory and add one for templates --- .../upgrade.test.ts => upgrade/index.test.ts} | 45 ++++++++++++++++++- .../src/fixtures/src/index.html | 8 ---- .../src/fixtures/src/input.css | 3 -- .../src/fixtures/tailwind.config.js | 4 -- .../src/template/parseConfig.ts | 7 +-- 5 files changed, 45 insertions(+), 22 deletions(-) rename integrations/{cli/upgrade.test.ts => upgrade/index.test.ts} (74%) delete mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/index.html delete mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/input.css delete mode 100644 packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js 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-upgrade/src/fixtures/src/index.html b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html deleted file mode 100644 index 0b7dfc0ddc02..000000000000 --- a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - My Tailwind CSS Upgrade Example - - -
- - diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/input.css b/packages/@tailwindcss-upgrade/src/fixtures/src/input.css deleted file mode 100644 index b5c61c956711..000000000000 --- a/packages/@tailwindcss-upgrade/src/fixtures/src/input.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js b/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js deleted file mode 100644 index a3687ebedc54..000000000000 --- a/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./src/**/*.{html,js}'], -} diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts index ac32e7f359dc..3c780880cf36 100644 --- a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -1,17 +1,12 @@ import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node' -import dedent from 'dedent' import type { DesignSystem } from '../../../tailwindcss/src/design-system' -let css = dedent export async function parseConfig( path: string, options: { base: string }, ): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { // TODO: base path needs to be resolved to v4 - let input = css` - @import 'tailwindcss'; - @config './${path}'; - ` + let input = `@import 'tailwindcss';\n@config './${path}'` let [compiler, designSystem] = await Promise.all([ compile(input, { ...options, onDependency: () => {} }), From dc3f99e7284b4e1d5b4bfc21f293a3e142362f8b Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 12:21:40 +0200 Subject: [PATCH 10/17] Cleanup --- packages/@tailwindcss-upgrade/src/template/candidates.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index bc754ab6f6e2..634cbe5e92e4 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -205,7 +205,6 @@ describe('toString()', () => { // Sometimes we will have a functional and a static candidate for the same // raw input string (e.g. `-inset-full`). Dedupe in this case. - // TODO: This seems unexpected? let cleaned = new Set([...candidates].map(printCandidate)) expect([...cleaned]).toEqual([result]) From 6a314439e2ec6ef01a2495f726f2240493f6517e Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 14:29:16 +0200 Subject: [PATCH 11/17] Fix integration test and make it so our dummy CSS file can load tailwind --- .../src/template/parseConfig.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts index 3c780880cf36..12c54014e494 100644 --- a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -1,16 +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( - path: string, + configPath: string, options: { base: string }, ): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { - // TODO: base path needs to be resolved to v4 - let input = `@import 'tailwindcss';\n@config './${path}'` + // 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, { ...options, onDependency: () => {} }), - __unstable__loadDesignSystem(input, options), + compile(input, { base: __dirname, onDependency: () => {} }), + __unstable__loadDesignSystem(input, { base: __dirname }), ]) return { designSystem, globs: compiler.globs } } From aec97643407b3944e49fc21725d0345fbd1f309b Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 15:36:59 +0200 Subject: [PATCH 12/17] Apply suggestions from code review Co-authored-by: Robin Malfait --- crates/node/src/lib.rs | 4 ++-- packages/@tailwindcss-upgrade/src/template/candidates.ts | 2 +- .../src/template/codemods/migrate-important.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 5c9baa67bc0d..c494f008ae1a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -85,10 +85,10 @@ pub struct Scanner { #[derive(Debug, Clone)] #[napi(object)] pub struct CandidateWithPosition { - // The candidate string + /// The candidate string pub candidate: String, - // The position of the candidate inside the content file + /// The position of the candidate inside the content file pub position: i64, } diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index be850fc65ee5..a3f4ef899f97 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,6 +1,6 @@ import { Scanner } from '@tailwindcss/oxide' import stringByteSlice from 'string-byte-slice' -import { type Candidate, type Variant } from '../../../tailwindcss/src/candidate' +import type { Candidate, Variant } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' export async function extractCandidates( diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts index 703481aa4b2c..44a23a1cb4b7 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts @@ -13,7 +13,7 @@ import type { Candidate } from '../../../../tailwindcss/src/candidate' // // flex! md:block! export function migrateImportant(candidate: Candidate): Candidate | null { - if (candidate.important) { + 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. From 69b9bdbc89e35930a931ff3aa2b54123970761f7 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 15:37:44 +0200 Subject: [PATCH 13/17] Remove accidential break --- packages/@tailwindcss-upgrade/src/template/migrate.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 2decefc76781..c46be2afa1a4 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -25,7 +25,6 @@ export default async function migrateContents( if (migrated) { candidate = migrated needsMigration = true - break } } From de8015fe1d6e350218ad6cc61980ffe92677e79a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 15:39:38 +0200 Subject: [PATCH 14/17] Add a test case for already-migrated cases --- .../template/codemods/migrate-important.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts index 40da40d590f9..0755dd41a808 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -8,7 +8,7 @@ let html = dedent test('applies the migration', async () => { let content = html` -
+
` @@ -18,8 +18,20 @@ test('applies the migration', async () => { }) 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(` + "
" + `) +}) From 6798c34923a2c4dd4e52cd336f6812edd8e39d42 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 16:01:14 +0200 Subject: [PATCH 15/17] Add change log entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From d26eacb9e6a7f1e2ee51bac79bd2e3d5078e647f Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 16:04:09 +0200 Subject: [PATCH 16/17] Update packages/@tailwindcss-upgrade/src/template/candidates.ts Co-authored-by: Jordan Pittman --- packages/@tailwindcss-upgrade/src/template/candidates.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index a3f4ef899f97..7833279ca0b2 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -135,8 +135,8 @@ function printVariant(variant: Variant) { function escapeArbitrary(input: string) { return input - .replaceAll(String.raw`_`, String.raw`\_`) // Escape underscores to keep them as-is - .replaceAll(String.raw` `, String.raw`_`) // Replace spaces with underscores + .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is + .replaceAll(' ', '_') // Replace spaces with underscores } export function replaceCandidateInContent( From ded53c2883437b4c4e59315036ae2eeddd2a8fa0 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 16:06:37 +0200 Subject: [PATCH 17/17] Simplify functional variant printing --- packages/@tailwindcss-upgrade/src/template/candidates.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index 7833279ca0b2..19b61d996217 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -101,14 +101,13 @@ function printVariant(variant: Variant) { // Handle functional variants if (variant.kind === 'functional') { + base += variant.root if (variant.value) { if (variant.value.kind === 'arbitrary') { - base += `${variant.root}-[${escapeArbitrary(variant.value.value)}]` + base += `-[${escapeArbitrary(variant.value.value)}]` } else if (variant.value.kind === 'named') { - base += `${variant.root}-${variant.value.value}` + base += `-${variant.value.value}` } - } else { - base += variant.root } }