Una eliminación de Tailwind es un problema de refactor antes que un problema de CSS. El repo ya codifica un design system; lo que pasa es que expresa cada componente como un string de utility en vez de como un nombre. Nuestro trabajo es leer lo que hay, poner nombre a los patrones recurrentes, y reescribir el JSX para que cada línea diga qué es el elemento. Los píxeles se quedan donde están. El código vuelve a ser legible.
Los cinco archivos de abajo componen la pipeline. El extractor de clases que produce el audit, el transformador de tokens que escribe las variables CSS, el codemod que reescribe el JSX, el bridge dark-mode que retira el prefijo dark:, y la regla de lint que mantiene la línea.
1. El extractor de clases
Leemos cada archivo .tsx y .jsx del repo, sacamos cada literal de className, y contamos las utility class. La salida es un CSV que consume el resto de la pipeline. El extractor usa un pequeño pase AST (no una regex), porque las clases Tailwind aparecen en template literals, dentro de llamadas a cn() y en expresiones condicionales, y una regex se pierde la segunda y la tercera 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. El transformador de tokens
El transformador lee tailwind.config.ts (o el theme Tailwind resuelto) y escribe un solo archivo CSS con cada token usado. Los tokens de color cogen un valor light y otro dark; spacing, radius, font-size y shadow cogen uno solo. El naming es semántico. bg-zinc-900 no pasa a --bg-zinc-900; pasa a --color-bg en el 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: {} }
// Colores: mapeo semántico, firmado por el diseñador
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: escala de 4 px, seis pasos
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. El codemod JSX
El codemod recorre los mismos archivos que recorrió el extractor, encuentra los strings de utility recurrentes, y los sustituye por el nombre de la BEM class. La tabla de sustituciones es un JSON que el desarrollador revisa antes del run. El codemod produce un diff; nada aterriza sin un ojo humano.
// 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. El bridge de dark mode
El bridge hace dos cosas a la vez: arranca el prefijo dark: de cada clase Tailwind y reconecta el JSX a leer de los tokens. Los tokens ya tienen los valores dark gobernados por [data-theme], así que en cuanto desaparece el prefijo, el dark mode sigue funcionando. El bridge es un pase único; corre una vez por archivo y el archivo está listo.
// 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')
// Quita cada token `dark:className`; ahora el valor dark vive en los tokens.
const rewritten = src.replaceAll(DARK_PREFIX, '')
.replace(/\s+"/g, '"') // cierra los espacios que deja el strip
.replace(/"\s+/g, '"')
if (rewritten !== src) {
await writeFile(file, rewritten, 'utf8')
}
}
5. La regla de lint que mantiene la línea
Una vez que sale la migración, la regresión más probable es un desarrollador que por costumbre vuelve a escribir <div className="flex items-center px-4">. La regla de Stylelint de abajo escanea los archivos .tsx y hace fallar la build cuando aparece una utility Tailwind conocida en el JSX. La regla lee la misma lista que produjo el extractor; se mantiene sincronizada.
// 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 de Tailwind "${cls}" ya no está permitida. Usa un componente con nombre o una BEM class.`,
})
}
}
},
}
},
}
6. Lo que esto compone
El extractor produce el audit. El transformador escribe los tokens. El codemod sustituye los strings recurrentes por nombres. El bridge jubila las variantes dark. La regla de lint impide que el trabajo retroceda.
Tailwind sale; en su sitio queda el design system. El bundle CSS es un tercio del que era. El dark mode es un toggle, no un prefijo en cada línea. El JSX se lee: cada componente dice qué es. El diseñador cambia un valor en un solo sitio, y la siguiente PR entrega el cambio en todas partes. El equipo que tardaba dos días en refrescar un tema ahora lo hace en una tarde, y el que entra nuevo abre un archivo de componente sin tener que tener la referencia de Tailwind abierta al lado.