Una rimozione di Tailwind è un problema di refactor prima che un problema di CSS. Il repo codifica già un design system; semplicemente esprime ogni componente come una stringa di utility invece che come un nome. Il nostro lavoro è leggere quello che c'è, dare un nome ai pattern ricorrenti, e riscrivere la JSX in modo che ogni riga dica cosa è l'elemento. I pixel restano dove sono. Il codice torna leggibile.
I cinque file qui sotto compongono la pipeline. L'estrattore di classi che produce l'audit, il trasformatore di token che scrive le variabili CSS, il codemod che riscrive la JSX, il bridge dark-mode che ritira il prefisso dark:, e la regola di lint che tiene la linea.
1. L'estrattore di classi
Leggiamo ogni file .tsx e .jsx nel repo, estraiamo ogni literal di className, e contiamo le utility class. L'output è un CSV che il resto della pipeline consuma. L'estrattore usa un piccolo passaggio AST (non una regex), perché le classi Tailwind compaiono in template literal, dentro chiamate a cn() e in espressioni condizionali, e una regex si perde la seconda e la terza forma.
// scripts/tailwind/extract-classes.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'
import { parse } from '@typescript-eslint/parser'
import type { TSESTree } from '@typescript-eslint/types'
const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })
const usage = new Map<string, { count: number; files: Set<string> }>()
function recordClasses(value: string, file: string): void {
for (const cls of value.split(/\s+/).filter(Boolean)) {
const entry = usage.get(cls) ?? { count: 0, files: new Set() }
entry.count += 1
entry.files.add(file)
usage.set(cls, entry)
}
}
function walkLiteral(node: TSESTree.Node, file: string): void {
if (node.type === 'Literal' && typeof node.value === 'string') {
recordClasses(node.value, file)
} else if (node.type === 'TemplateLiteral') {
for (const q of node.quasis) recordClasses(q.value.cooked ?? '', file)
}
}
for (const file of FILES) {
const src = await readFile(file, 'utf8')
const ast = parse(src, { jsx: true, range: false, loc: false })
const walk = (node: TSESTree.Node): void => {
if (node.type === 'JSXAttribute' && node.name.name === 'className' && node.value) {
if (node.value.type === 'Literal') walkLiteral(node.value, file)
if (node.value.type === 'JSXExpressionContainer') walkLiteral(node.value.expression, file)
}
for (const key of Object.keys(node) as Array<keyof TSESTree.Node>) {
const child = node[key] as unknown
if (Array.isArray(child)) for (const c of child) if (c && typeof c === 'object' && 'type' in c) walk(c as TSESTree.Node)
else if (child && typeof child === 'object' && 'type' in child) walk(child as TSESTree.Node)
}
}
walk(ast as unknown as TSESTree.Node)
}
const rows = [...usage.entries()]
.map(([cls, e]) => ({ cls, count: e.count, fileCount: e.files.size }))
.sort((a, b) => b.count - a.count)
const csv = ['class,count,file_count', ...rows.map((r) => `${r.cls},${r.count},${r.fileCount}`)].join('\n')
await writeFile('./out/tailwind-usage.csv', csv, 'utf8')
console.log(`extracted ${rows.length} unique classes across ${FILES.length} files`)
2. Il trasformatore di token
Il trasformatore legge tailwind.config.ts (o il theme Tailwind risolto) e scrive un solo file CSS con ogni token usato. I token colore prendono un valore light e uno dark; spacing, radius, font-size e shadow ne prendono uno solo. Il naming è semantico. bg-zinc-900 non diventa --bg-zinc-900; diventa --color-bg sul tema dark.
// scripts/tailwind/transform-tokens.ts
import { writeFile } from 'node:fs/promises'
import resolveConfig from 'tailwindcss/resolveConfig'
import twConfig from '../../tailwind.config'
const fullConfig = resolveConfig(twConfig)
const theme = fullConfig.theme
interface TokenMap {
light: Record<string, string>
dark: Record<string, string>
}
const tokens: TokenMap = { light: {}, dark: {} }
// Colori: mapping semantico, controfirmato dal designer
tokens.light['--color-bg'] = theme.colors.white as string
tokens.dark['--color-bg'] = (theme.colors.zinc as Record<string, string>)['900']
tokens.light['--color-text'] = (theme.colors.zinc as Record<string, string>)['900']
tokens.dark['--color-text'] = (theme.colors.zinc as Record<string, string>)['100']
tokens.light['--color-border'] = (theme.colors.zinc as Record<string, string>)['200']
tokens.dark['--color-border'] = (theme.colors.zinc as Record<string, string>)['800']
// Spacing: scala da 4 px, sei step
for (const step of [1, 2, 3, 4, 6, 8]) {
tokens.light[`--space-${step}`] = `${step * 4}px`
tokens.dark[`--space-${step}`] = `${step * 4}px`
}
const lines: string[] = []
lines.push(':root {')
for (const [k, v] of Object.entries(tokens.light)) lines.push(` ${k}: ${v};`)
lines.push('}', '')
lines.push('[data-theme="dark"] {')
for (const [k, v] of Object.entries(tokens.dark)) lines.push(` ${k}: ${v};`)
lines.push('}', '')
await writeFile('./src/styles/tokens.css', lines.join('\n'), 'utf8')
console.log(`wrote ${Object.keys(tokens.light).length} tokens`)
3. Il codemod JSX
Il codemod cammina sugli stessi file che l'estrattore ha camminato, trova le stringhe di utility ricorrenti, e le sostituisce con il nome della BEM class. La tabella di sostituzione è un JSON che lo sviluppatore rivede prima del run. Il codemod produce un diff; nulla atterra senza un occhio umano.
// scripts/tailwind/codemod.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'
interface Replacement {
pattern: string
replacement: string
reason: string
}
const REPLACEMENTS: Replacement[] = JSON.parse(
await readFile('./data/replacements.json', 'utf8'),
)
const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })
let totalReplacements = 0
for (const file of FILES) {
let src = await readFile(file, 'utf8')
let replacementsHere = 0
for (const r of REPLACEMENTS) {
const before = src
src = src.replaceAll(r.pattern, r.replacement)
if (src !== before) replacementsHere += 1
}
if (replacementsHere > 0) {
await writeFile(file, src, 'utf8')
totalReplacements += replacementsHere
console.log(`${file}: ${replacementsHere} replacements`)
}
}
console.log(`total: ${totalReplacements} replacements across ${FILES.length} files`)
4. Il bridge dark-mode
Il bridge fa due cose insieme: strippa il prefisso dark: da ogni classe Tailwind e ricabla la JSX a leggere dai token. I token hanno già i valori dark gestiti da [data-theme], quindi appena il prefisso sparisce, il dark mode continua a funzionare. Il bridge è un passaggio singolo; gira una volta per file e il file è a posto.
// scripts/tailwind/strip-dark-prefix.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'
const DARK_PREFIX = /dark:(\S+)/g
const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })
for (const file of FILES) {
const src = await readFile(file, 'utf8')
// Toglie ogni token `dark:className`; ora il valore dark vive nei token.
const rewritten = src.replaceAll(DARK_PREFIX, '')
.replace(/\s+"/g, '"') // chiude gli spazi introdotti dallo strip
.replace(/"\s+/g, '"')
if (rewritten !== src) {
await writeFile(file, rewritten, 'utf8')
}
}
5. La regola di lint che tiene la linea
Una volta uscita la migrazione, la regressione più probabile è uno sviluppatore che per abitudine torna a scrivere <div className="flex items-center px-4">. La regola Stylelint qui sotto scansiona i file .tsx e fa fallire la build quando una utility Tailwind nota compare nella JSX. La regola legge la stessa lista che ha prodotto l'estrattore; resta sincronizzata.
// stylelint/rules/no-tailwind-utilities.js
const KNOWN_TAILWIND = require('./tailwind-utility-list.json')
module.exports = {
meta: { type: 'problem' },
create(context) {
return {
JSXAttribute(node) {
if (node.name.name !== 'className') return
const value = node.value
if (!value || value.type !== 'Literal') return
const classes = String(value.value).split(/\s+/).filter(Boolean)
for (const cls of classes) {
if (KNOWN_TAILWIND.includes(cls)) {
context.report({
node,
message: `La utility Tailwind "${cls}" non è più consentita. Usa un componente con un nome o una BEM class.`,
})
}
}
},
}
},
}
6. Cosa compone questo
L'estrattore produce l'audit. Il trasformatore scrive i token. Il codemod sostituisce le stringhe ricorrenti con nomi. Il bridge manda in pensione le varianti dark. La regola di lint impedisce al lavoro di tornare indietro.
Tailwind sparisce; al suo posto c'è il design system. Il bundle CSS è un terzo di quello che era. Il dark mode è un toggle, non un prefisso su ogni riga. La JSX si legge: ogni componente dice cosa è. Il designer cambia un valore in un posto solo, e la PR successiva spedisce il cambio ovunque. Il team che impiegava due giorni per un refresh del tema ora lo fa in un pomeriggio, e chi entra il primo giorno apre un file di componente senza dover tenere aperta la reference di Tailwind.