DEV Community

Sergio Azócar
Sergio Azócar

Posted on • Edited on • Originally published at sergioazocar.com

oxlint-tailwindcss: el plugin de linting que Tailwind v4 necesitaba

Del problema al open-source: la oportunidad perfecta para contribuir

El día que el equipo de oxc abrió el alpha de plugins nativos para oxlint, fue la excusa perfecta para solucionar un problema que llevaba tiempo teniendo en mi trabajo actual, lintear las clases de Tailwind CSS con oxlint.

Si usas Tailwind CSS v4 con oxlint, las opciones de linting existentes no están pensadas para ese combo. eslint-plugin-tailwindcss es sólido pero vive en el mundo de ESLint y su soporte de v4 todavía es parcial. eslint-plugin-better-tailwindcss funciona en oxlint a través de la capa de compatibilidad jsPlugins, y hace el trabajo — pero no es un plugin nativo y sus reglas son más acotadas. Ninguno fue diseñado específicamente para oxlint + Tailwind CSS v4.

Así que lo construí.

Qué es oxlint-tailwindcss

Un plugin nativo de oxlint con 23 reglas de linting diseñadas exclusivamente para Tailwind CSS v4. No es un port de ESLint ni un wrapper, usa directamente la API de @oxlint/plugins. Solo dos dependencias en runtime, @tailwindcss/node y tailwindcss.

Esto importa porque al ser nativo, comparte el mismo ciclo de parseo que oxlint. No hay overhead de interoperabilidad, no hay capa de traducción. Es tan rápido como oxlint mismo y se nota.

Determinista por diseño

Para validar tus clases, el plugin lee tu design system real. Y para eso necesita una sola cosa, el entry point de tu CSS, el archivo con @import "tailwindcss" y tus tokens de @theme. Lo declaras una vez en settings.tailwindcss.entryPoint y las 23 reglas validan contra el mismo design system.

{
  "jsPlugins": ["oxlint-tailwindcss"],
  "settings": {
    "tailwindcss": {
      "entryPoint": "src/styles.css", // tu CSS con @import "tailwindcss"
    },
  },
  "rules": {
    "tailwindcss/no-unknown-classes": "error",
    "tailwindcss/no-conflicting-classes": "error",
    "tailwindcss/enforce-sort-order": "warn",
  },
}
Enter fullscreen mode Exit fullscreen mode

¿Por qué declararlo a mano en vez de adivinarlo? Porque adivinar depende del filesystem, y eso significa que el mismo código puede dar resultados distintos en tu máquina y en CI. Declarar el entry point explícito es determinista, mismo input, mismo output, en cualquier máquina. Es el mismo principio que sigue oxlint, y prefiero un plugin que sea predecible antes que uno que intente ser mágico.

Por la misma razón, si te equivocas en la config el plugin no se queda callado saltándose reglas. Tira un único diagnóstico designSystemUnavailable con una pista de qué arreglar. Falla fuerte y claro.

El plugin lee tu CSS llamando directamente a @tailwindcss/node, así que entiende tus tokens de @theme, las variables de shadcn y plugins como @tailwindcss/typography o tailwindcss-animate. El design system se computa una vez y se cachea en disco con content-hash, sin recalcular en cada corrida.

En un monorepo con varios design systems mapeas globs a entry points, y el primer match gana:

{
  "settings": {
    "tailwindcss": {
      "entryPoint": [
        { "files": "packages/ui/**", "use": "packages/ui/src/styles.css" },
        { "files": "packages/web/**", "use": "packages/web/src/app.css" },
        { "files": "**", "use": "src/global.css" },
      ],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

El "**" al final es el fallback para todo lo que quede fuera de los globs explícitos. Si prefieres, también puedes tener un .oxlintrc.json por paquete, las dos formas son 100% deterministas.

23 reglas en cuatro categorías

Correctness — Evitar errores reales

Las reglas de correctness atrapan bugs antes de que lleguen al navegador.

no-unknown-classes detecta clases que no existen en tu design system y sugiere correcciones para typos:

<div className="flex itms-center bg-blu-500" />
//                   ^^^^^^^^^^^
// "itms-center" is not a valid Tailwind CSS class.
// Did you mean "items-center"?
Enter fullscreen mode Exit fullscreen mode

Soporta allowlist para permitir clases custom que no están en tu design system e ignorePrefixes para saltarse prefijos que no son clases de Tailwind.

no-conflicting-classes te dice exactamente qué propiedad CSS está en conflicto y cuál clase gana:

<div className="text-red-500 text-blue-500" />
// "text-red-500" and "text-blue-500" affect "color".
// "text-blue-500" takes precedence (appears later).
Enter fullscreen mode Exit fullscreen mode

no-dark-without-light detecta cuando usas dark: sin una clase base, algo que suele causar estilos faltantes en light mode:

// ❌ — ¿qué fondo tiene en light mode?
<div className="dark:bg-gray-900" />

// ✅
<div className="bg-white dark:bg-gray-900" />
Enter fullscreen mode Exit fullscreen mode

no-dark-without-light chequea dark: por defecto, pero la opción variants permite aplicar el mismo patrón a cualquier variante — útil si tu proyecto usa variantes custom.

no-contradicting-variants atrapa variantes redundantes donde la clase base ya aplica incondicionalmente:

// ❌ — dark:flex es redundante, flex ya aplica siempre
<div className="flex dark:flex" />
Enter fullscreen mode Exit fullscreen mode

no-deprecated-classes reemplaza automáticamente las clases deprecadas en v4:

// ❌ v3
<div className="flex-grow overflow-ellipsis decoration-slice" />

// ✅ v4 (autofix)
<div className="grow text-ellipsis box-decoration-slice" />
Enter fullscreen mode Exit fullscreen mode

También flex-shrinkshrink y decoration-clonebox-decoration-clone.

Y las clásicas no-duplicate-classes (con autofix) y no-unnecessary-whitespace.

Style — Consistencia del equipo

enforce-sort-order ordena las clases según el orden oficial de Tailwind CSS (con autofix), compatible con oxfmt y prettier-plugin-tailwindcss. Su modo strict agrupa las clases por prefijo de variante, ordena dentro de cada grupo y ordena los grupos por prioridad de variante.

enforce-shorthand convierte mt-2 mr-2 mb-2 ml-2 en m-2, w-full h-full en size-full, y muchas más combinaciones. Todo con autofix.

enforce-logical convierte propiedades físicas en lógicas para soporte LTR/RTL: ml-4ms-4, left-0start-0. Su inversa, enforce-physical, hace lo contrario para proyectos que son solo LTR y prefieren consistencia con propiedades físicas. Ambas con autofix.

enforce-consistent-variable-syntax normaliza la sintaxis de variables CSS entre bg-[var(--primary)] y la shorthand de v4 bg-(--primary).

enforce-canonical convierte valores arbitrarios a clases nativas cuando existen: p-[2px]p-0.5 (usa rootFontSize de 16px por defecto para la conversión). Funciona directo con la API de canonicalización de Tailwind.

enforce-consistent-important-position (default suffix, la forma canónica de v4), enforce-negative-arbitrary-values (-top-[5px]top-[-5px]) y consistent-variant-order completan las reglas de estilo.

Complexity — Mantener el código manejable

max-class-count avisa cuando un elemento supera las 20 clases (configurable). Es la señal de que es hora de extraer un componente.

enforce-consistent-line-wrapping controla el largo del string de clases por print width o por cantidad de clases por línea.

Restrictions — Reglas del design system

no-hardcoded-colors prohíbe colores hardcodeados como bg-[#ff5733] en brackets arbitrarios — el típico atajo que erosiona tu design system.

no-arbitrary-value y no-unnecessary-arbitrary-value (con autofix) controlan el uso de valores arbitrarios. La segunda detecta cuando usas h-[auto] pero existe h-auto.

prefer-theme-tokens detecta cuando referencias una variable CSS a mano (bg-[var(--primary)] o la shorthand bg-(--primary)) y existe la utilidad nombrada del token en tu design system, y la reescribe a bg-primary. Respeta los modificadores de opacidad, las variantes y el !important. Con autofix.

no-restricted-classes permite bloquear clases específicas por nombre o regex, con mensajes custom.

Extracción de clases

El parser es lo que hace que todo esto funcione de manera confiable. No es un regex que busca className= y reza. Extrae clases de:

  • Atributos JSX (className, class)
  • Atributos JSX con objetos — e.g. el prop classNames de Mantine: <Input classNames={{ root: "flex", input: "border-none" }} />
  • Template literals con interpolación
  • Ternarios
  • Funciones de utilidad: cn(), clsx(), cx(), cva(), twMerge(), twJoin(), y más
  • cva() completo — base, variants, compoundVariants
  • tv() completo — base, slots, variants con objetos de slots, compoundSlots
  • classed() (tw-classed) — ignora el tipo de elemento, extrae clases y config estilo cva
  • Tagged templates (tw\...``)
  • Variables por nombre (className, classes, style, styles)
  • Clases de componentes definidas con @layer components { .btn {} } en tu CSS

Maneja nested brackets, calc anidado, arbitrary variants, quoted values, important modifier, negative values y named groups/peers. Los edge cases que rompen otros parsers.

Detección customizable

Por defecto el plugin detecta clases en atributos comunes, 14 funciones de utilidad, tagged templates tw, y variables con nombres como className/classes/style. Puedes extender estos defaults vía settings.tailwindcss — todos los valores son aditivos:

`jsonc
{
"settings": {
"tailwindcss": {
"attributes": ["overlayClassName"],
"callees": ["myHelper"],
"tags": ["css"],
"variablePatterns": ["^tw"],
},
},
}
`

Esto aplica a las 23 reglas de una vez. Si necesitas quitar un default built-in, usa exclude en el mismo bloque de settings.

La historia detrás

Partí planificando qué quería, el stack que iba a usar y cómo quería que funcionara todo. Después de planificar la implementación con Claude Code, arrancó la iteración hasta conseguir las 23 reglas actuales. El repo incluye un CLAUDE.md y skills configuradas que permiten a cualquier contribuidor usar el mismo workflow para escribir reglas nuevas — la misma herramienta con la que se construyó el plugin. Si quieres agregar una regla, Claude Code ya sabe cómo hacerlo en este proyecto.

El proyecto corre completamente sobre el ecosistema de herramientas de VoidZero. tsdown para el build, oxfmt para el formateo, vitest para testing, tsgo (TypeScript 7 nativo en Go) para el type checking, y por supuesto oxlint para el linting del propio plugin. Cada herramienta en la cadena está construida sobre Rust u optimizada para velocidad.

No fue una decisión cosmética, fue dogfooding deliberado. Si vas a hacer un plugin para oxlint, tiene sentido que todo el toolchain sea del mismo ecosistema. Y si vas a desarrollar con un agente de IA, tiene sentido que el repo esté preparado para ello.

Cómo empezar

Necesitas oxlint ≥ 1.43.0, Tailwind CSS v4 y Node.js ≥ 20. Instala el plugin:

`bash
pnpm add -D oxlint-tailwindcss
`

Agrega el plugin, tu entry point y las reglas a tu .oxlintrc.json:

`jsonc
{
"jsPlugins": ["oxlint-tailwindcss"],
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css", // tu CSS con @import "tailwindcss"
},
},
"rules": {
"tailwindcss/no-unknown-classes": "error",
"tailwindcss/no-duplicate-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/no-deprecated-classes": "error",
"tailwindcss/no-unnecessary-whitespace": "error",
"tailwindcss/enforce-sort-order": "warn",
"tailwindcss/enforce-shorthand": "warn",
"tailwindcss/no-hardcoded-colors": "warn",
//...
},
}
`

Ejecuta oxlint. Eso es todo.

Pruébalo

El plugin es funcional, testeado y usado en producción. Pero un linter se hace mejor con feedback real de proyectos reales.
Si lo pruebas y encuentras un caso que no maneja bien, abre un issue. Si quieres contribuir una regla, el repo ya está preparado para que iteres con Claude Code desde el primer minuto. Y si simplemente te resultó útil, una estrella en GitHub ayuda a que más gente lo encuentre.


GitHub · npm

Top comments (0)