diff --git a/CLAUDE.md b/CLAUDE.md index f3266af..0a920bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -728,8 +728,15 @@ AST visitors: `JSXAttribute`, `CallExpression`, `TaggedTemplateExpression`, `Var object value — and write the same block between the quotes: a JSX string may span lines as written, a JS string can't. Its value keeps the file's `\r\n` (a template's is LF), so it is measured and wrapped in LF and written back with the file's line ending (`lineEndingOf`). - `multiline-preservation.test.ts` runs every fixer over both shapes; oxfmt's `sortTailwindcss` - collapses the block unless `preserveWhitespace: true` (`oxfmt-interop.test.ts`). + `wrapStrings: 'all'` adds JS strings, turned into a template literal as better-tailwindcss does: + the fix replaces the whole `Literal` (`node.range`, quotes included) with backticks around the + block, which is why `meta.fixable` is `'code'`. `isConvertibleJsString` takes only a string whose + parent accepts any expression (a call argument, `JSXExpressionContainer`, array element, object + VALUE, ternary branch, `&&`/`||` operand, variable init) — never an object key or a `+` operand — + and whose source between the quotes equals its value and holds no `` ` `` or `${`, so a template + reads it the same. `multiline-preservation.test.ts` runs every fixer over both shapes (a converted + string IS a template afterwards); oxfmt's `sortTailwindcss` collapses either block, a helper's + template included, unless `preserveWhitespace: true` (`oxfmt-interop.test.ts`). - **Worker services lifecycle**: `sort-service.ts`, `canonicalize-service.ts` and `declaration-service.ts` are thin wrappers around `DesignSystemWorker` (in `design-system/ds-worker.ts`). The class owns the SharedArrayBuffer + Atomics protocol and the diff --git a/packages/docs/data/better-tailwindcss.json b/packages/docs/data/better-tailwindcss.json index 9fb8f40..5dd3d95 100644 --- a/packages/docs/data/better-tailwindcss.json +++ b/packages/docs/data/better-tailwindcss.json @@ -25,8 +25,8 @@ "ours": "enforce-consistent-line-wrapping", "example": "
x
", "notes": { - "en": "`printWidth`, `classesPerLine` and `group` mean the same. The autofix rewrites template literals, and a JSX string attribute only with `wrapStrings: \"jsx\"` (otherwise it is reported, not rewritten); a line over `printWidth` is rewrapped only with `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` and `vueConvertToBinding` have no equivalent: the indentation and line endings come from the source.", - "es": "`printWidth`, `classesPerLine` y `group` significan lo mismo. El autofix reescribe template literals, y un atributo string de JSX solo con `wrapStrings: \"jsx\"` (si no, se reporta y no se reescribe); una línea que pasa el `printWidth` se vuelve a envolver solo con `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` y `vueConvertToBinding` no tienen equivalente: la indentación y los finales de línea salen del código." + "en": "`printWidth`, `classesPerLine` and `group` mean the same. The autofix rewrites template literals; with `wrapStrings: \"all\"` it wraps strings the way theirs does, a JSX string attribute in place and a JS string turned into a template literal (`\"jsx\"` wraps only the former, and otherwise a string is reported, not rewritten). A line over `printWidth` is rewrapped only with `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` and `vueConvertToBinding` have no equivalent: the indentation and line endings come from the source.", + "es": "`printWidth`, `classesPerLine` y `group` significan lo mismo. El autofix reescribe template literals; con `wrapStrings: \"all\"` envuelve los strings como lo hace la suya, un atributo string de JSX en su lugar y un string de JS convertido en template literal (`\"jsx\"` envuelve solo lo primero, y si no, el string se reporta y no se reescribe). Una línea que pasa el `printWidth` se vuelve a envolver solo con `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` y `vueConvertToBinding` no tienen equivalente: la indentación y los finales de línea salen del código." } }, { diff --git a/packages/docs/es/interop.md b/packages/docs/es/interop.md index 0e0beea..eb701ca 100644 --- a/packages/docs/es/interop.md +++ b/packages/docs/es/interop.md @@ -56,10 +56,11 @@ El formateador además elimina clases duplicadas y colapsa espacios en los strin que ahí se solapa con `no-duplicate-classes` y `no-unnecessary-whitespace`; las reglas de lint siguen cubriendo todo lo demás. -Eso incluye los saltos de línea de un atributo de clases que `enforce-consistent-line-wrapping` -envolvió en un bloque (`wrapStrings: "jsx"`): el formateador lo vuelve a dejar en una línea, y cada -uno deshace lo del otro. Pon `preserveWhitespace: true` en `sortTailwindcss` para conservar el -bloque. +Eso incluye los saltos de línea de un string de clases que `enforce-consistent-line-wrapping` +envolvió en un bloque, ya sea un atributo de clases (`wrapStrings: "jsx"`) o un template en un +helper que el formateador ordena (`cn(…)` con `wrapStrings: "all"`): el formateador lo vuelve a +dejar en una línea, y cada uno deshace lo del otro. Pon `preserveWhitespace: true` en +`sortTailwindcss` para conservar el bloque. ::: tip `--fix` más de una vez diff --git a/packages/docs/es/migration/from-better-tailwindcss.md b/packages/docs/es/migration/from-better-tailwindcss.md index 66c96bf..f4602bc 100644 --- a/packages/docs/es/migration/from-better-tailwindcss.md +++ b/packages/docs/es/migration/from-better-tailwindcss.md @@ -15,23 +15,23 @@ versión. -| better-tailwindcss | oxlint-tailwindcss | Notas | -| --------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enforce-consistent-class-order` | [`enforce-sort-order`](/es/rules/enforce-sort-order) | Su `order: "official"` por defecto es el orden de esta regla. Las clases desconocidas van primero, en el orden en que están escritas, como con su `unknownClassOrder: "preserve"`. `order: "strict"` → `mode: "strict"`, que también agrupa las clases por cadena de variants (el orden dentro del grupo puede variar). `asc`, `desc` y las opciones `componentClass*` / `unknownClass*` no tienen equivalente. | -| `enforce-consistent-important-position` | [`enforce-consistent-important-position`](/es/rules/enforce-consistent-important-position) | `position: "recommended"` → `"suffix"` (el default, `p-4!`); `"legacy"` → `"prefix"` (`!p-4`). | -| `enforce-consistent-line-wrapping` | [`enforce-consistent-line-wrapping`](/es/rules/enforce-consistent-line-wrapping) | `printWidth`, `classesPerLine` y `group` significan lo mismo. El autofix reescribe template literals, y un atributo string de JSX solo con `wrapStrings: "jsx"` (si no, se reporta y no se reescribe); una línea que pasa el `printWidth` se vuelve a envolver solo con `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` y `vueConvertToBinding` no tienen equivalente: la indentación y los finales de línea salen del código. | -| `enforce-consistent-variant-order` | [`consistent-variant-order`](/es/rules/consistent-variant-order) | Las dos escriben las cadenas de afuera hacia adentro (`sm:hover:`). Esta regla además ordena color scheme y variants de atributos (`dark:hover:`, `data-[state=open]:hover:`), que la suya deja como están; `order` fija el tuyo. | -| `enforce-consistent-variable-syntax` | [`enforce-consistent-variable-syntax`](/es/rules/enforce-consistent-variable-syntax) | `syntax: "shorthand"` es el default en las dos; `"variable"` → `"explicit"`. | -| `enforce-shorthand-classes` | [`enforce-shorthand`](/es/rules/enforce-shorthand) | | -| `enforce-logical-properties` | [`enforce-logical`](/es/rules/enforce-logical) | `ignore` → `allowlist` (también expresiones regulares). El eje block (`mt-*` → `mbs-*`, `top-*` → `inset-bs-*`) viene activo; anchos y altos (`w-*` → `inline-*`, `size-*` → `inline-* block-*`) necesitan `sizing: true`. `direction` la limita a las utilities inline o block; `enforce-physical` es la regla inversa. | -| `enforce-canonical-classes` | [`enforce-canonical`](/es/rules/enforce-canonical) | Su `collapse` (juntar `mt-2 mb-2` en `my-2`) aquí es `enforce-shorthand`. `ignore` y `logical` no tienen equivalente. | -| `no-duplicate-classes` | [`no-duplicate-classes`](/es/rules/no-duplicate-classes) | | -| `no-deprecated-classes` | [`no-deprecated-classes`](/es/rules/no-deprecated-classes) | | -| `no-unnecessary-whitespace` | [`no-unnecessary-whitespace`](/es/rules/no-unnecessary-whitespace) | Los saltos de línea siempre se conservan, como con su `allowMultiline: true`. | -| `no-concatenated-classes` | [`no-dynamic-classes`](/es/rules/no-dynamic-classes) | Las dos reportan una clase armada con `${}` o `+`. Esta la reporta cuando la clase armada empieza con una utility o una variant de Tailwind, así que `"p-4 " + extra` y `` `icon-${name}` `` están bien. | -| `no-unknown-classes` | [`no-unknown-classes`](/es/rules/no-unknown-classes) | `ignore` (expresiones regulares) → `allowlist` (nombres exactos) e `ignorePrefixes`. Las clases que define tu CSS (`@layer components`, `@utility`) siempre se conocen, así que su setting `detectComponentClasses` no tiene equivalente. | -| `no-conflicting-classes` | [`no-conflicting-classes`](/es/rules/no-conflicting-classes) | También reporta una clase que otra vuelve redundante (`reportRedundant`), y `allow` exime pares. | -| `no-restricted-classes` | [`no-restricted-classes`](/es/rules/no-restricted-classes) | `restrict: [{ pattern, message }]` → `patterns: [{ pattern, message }]`, y `classes` para nombres exactos. No hay `fix`. | +| better-tailwindcss | oxlint-tailwindcss | Notas | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enforce-consistent-class-order` | [`enforce-sort-order`](/es/rules/enforce-sort-order) | Su `order: "official"` por defecto es el orden de esta regla. Las clases desconocidas van primero, en el orden en que están escritas, como con su `unknownClassOrder: "preserve"`. `order: "strict"` → `mode: "strict"`, que también agrupa las clases por cadena de variants (el orden dentro del grupo puede variar). `asc`, `desc` y las opciones `componentClass*` / `unknownClass*` no tienen equivalente. | +| `enforce-consistent-important-position` | [`enforce-consistent-important-position`](/es/rules/enforce-consistent-important-position) | `position: "recommended"` → `"suffix"` (el default, `p-4!`); `"legacy"` → `"prefix"` (`!p-4`). | +| `enforce-consistent-line-wrapping` | [`enforce-consistent-line-wrapping`](/es/rules/enforce-consistent-line-wrapping) | `printWidth`, `classesPerLine` y `group` significan lo mismo. El autofix reescribe template literals; con `wrapStrings: "all"` envuelve los strings como lo hace la suya, un atributo string de JSX en su lugar y un string de JS convertido en template literal (`"jsx"` envuelve solo lo primero, y si no, el string se reporta y no se reescribe). Una línea que pasa el `printWidth` se vuelve a envolver solo con `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` y `vueConvertToBinding` no tienen equivalente: la indentación y los finales de línea salen del código. | +| `enforce-consistent-variant-order` | [`consistent-variant-order`](/es/rules/consistent-variant-order) | Las dos escriben las cadenas de afuera hacia adentro (`sm:hover:`). Esta regla además ordena color scheme y variants de atributos (`dark:hover:`, `data-[state=open]:hover:`), que la suya deja como están; `order` fija el tuyo. | +| `enforce-consistent-variable-syntax` | [`enforce-consistent-variable-syntax`](/es/rules/enforce-consistent-variable-syntax) | `syntax: "shorthand"` es el default en las dos; `"variable"` → `"explicit"`. | +| `enforce-shorthand-classes` | [`enforce-shorthand`](/es/rules/enforce-shorthand) | | +| `enforce-logical-properties` | [`enforce-logical`](/es/rules/enforce-logical) | `ignore` → `allowlist` (también expresiones regulares). El eje block (`mt-*` → `mbs-*`, `top-*` → `inset-bs-*`) viene activo; anchos y altos (`w-*` → `inline-*`, `size-*` → `inline-* block-*`) necesitan `sizing: true`. `direction` la limita a las utilities inline o block; `enforce-physical` es la regla inversa. | +| `enforce-canonical-classes` | [`enforce-canonical`](/es/rules/enforce-canonical) | Su `collapse` (juntar `mt-2 mb-2` en `my-2`) aquí es `enforce-shorthand`. `ignore` y `logical` no tienen equivalente. | +| `no-duplicate-classes` | [`no-duplicate-classes`](/es/rules/no-duplicate-classes) | | +| `no-deprecated-classes` | [`no-deprecated-classes`](/es/rules/no-deprecated-classes) | | +| `no-unnecessary-whitespace` | [`no-unnecessary-whitespace`](/es/rules/no-unnecessary-whitespace) | Los saltos de línea siempre se conservan, como con su `allowMultiline: true`. | +| `no-concatenated-classes` | [`no-dynamic-classes`](/es/rules/no-dynamic-classes) | Las dos reportan una clase armada con `${}` o `+`. Esta la reporta cuando la clase armada empieza con una utility o una variant de Tailwind, así que `"p-4 " + extra` y `` `icon-${name}` `` están bien. | +| `no-unknown-classes` | [`no-unknown-classes`](/es/rules/no-unknown-classes) | `ignore` (expresiones regulares) → `allowlist` (nombres exactos) e `ignorePrefixes`. Las clases que define tu CSS (`@layer components`, `@utility`) siempre se conocen, así que su setting `detectComponentClasses` no tiene equivalente. | +| `no-conflicting-classes` | [`no-conflicting-classes`](/es/rules/no-conflicting-classes) | También reporta una clase que otra vuelve redundante (`reportRedundant`), y `allow` exime pares. | +| `no-restricted-classes` | [`no-restricted-classes`](/es/rules/no-restricted-classes) | `restrict: [{ pattern, message }]` → `patterns: [{ pattern, message }]`, y `classes` para nombres exactos. No hay `fix`. | diff --git a/packages/docs/es/rules/_extras/enforce-consistent-line-wrapping.md b/packages/docs/es/rules/_extras/enforce-consistent-line-wrapping.md index 4be1cff..452b58b 100644 --- a/packages/docs/es/rules/_extras/enforce-consistent-line-wrapping.md +++ b/packages/docs/es/rules/_extras/enforce-consistent-line-wrapping.md @@ -7,8 +7,9 @@ description: "Regla de oxlint que reporta strings de clases de Tailwind CSS que Marca strings de clases largos para que no se desparramen más allá de un largo de línea razonable y, cuando lo activas, los envuelve en varias líneas. Dos modos de formateo independientes: un re-wrap basado en el width (opt-in vía `wrapLines`) y un budget de clases por línea (`classesPerLine`), -ambos con autofix en template literals y, con `wrapStrings: "jsx"`, en el string propio de un -atributo JSX (los demás string literals solo reportan — un string de JS no puede cruzar líneas). +ambos con autofix en template literals y, con `wrapStrings`, también en string literals: el string +propio de un atributo JSX en su lugar (`"jsx"`), y un string de JS convertido en template (`"all"`). +Si no, los string literals solo reportan — un string de JS no puede cruzar líneas. DS-opcional — funciona sin `settings.tailwindcss.entryPoint`. La regla opera sobre el string crudo de clases y no le importa qué significan, con una excepción: cuando **sí** hay un design system @@ -69,12 +70,11 @@ alcance como en layout: del budget que no siguen ese layout reportan `inconsistentWrapping`. En un string literal de JS (`cn("…")`, `className={"…"}`, una variable) la regla reporta `tooLong` -sin fix sin importar `wrapLines`: un string de JS no puede contener un salto de línea real, y -convertirlo en un template multilínea es una decisión de criterio (extraer un componente vs. solo -wrappearlo), así que la regla saca el warning y te deja decidir. El string propio de un atributo JSX -sí se puede envolver tal cual — mira `wrapStrings`. Un fragmento de template **pegado** a un `${}` -sin whitespace (`` `${a}flex …` `` — una sola clase en runtime) tampoco se autofixea nunca: -cualquier whitespace introducido en ese borde partiría esa clase en dos. +sin fix por default: un string de JS no puede contener un salto de línea real, y convertirlo en un +template multilínea cambia el valor del string, así que eso es opt-in — mira `wrapStrings`, que +también puede envolver tal cual el string propio de un atributo JSX. Un fragmento de template +**pegado** a un `${}` sin whitespace (`` `${a}flex …` `` — una sola clase en runtime) no se +autofixea nunca: cualquier whitespace introducido en ese borde partiría esa clase en dos. Alrededor de una interpolación, el layout `"all"` pone el run de clases que bordea un `${}` en su **propia línea nueva** (nunca colgando inline después de la expresión), así que toda línea reescrita @@ -127,8 +127,8 @@ líneas que exceden), y el fixer de `classesPerLine` corta por conteo, así que Cantidad máxima de clases en una sola línea. Cuando se excede dentro de un template literal (`` `…` ``), la regla autofixea envolviendo las clases en la convención bloque, en chunks de -`classesPerLine` por línea. Dentro de un string literal de JS la regla reporta `tooManyPerLine` pero -no autofixea; un string de atributo JSX también se envuelve con `wrapStrings: "jsx"`. +`classesPerLine` por línea. Dentro de un string literal la regla reporta `tooManyPerLine` sin fix, a +menos que `wrapStrings` lo cubra. Establecer `classesPerLine` cambia el fixer de template literals a este modo por chunks y apaga el fixer basado en el width (agrupado por variantes) — `printWidth` entonces solo reporta, y @@ -140,12 +140,14 @@ fixer basado en el width (agrupado por variantes) — `printWidth` entonces solo ### `wrapStrings` -`"never" | "jsx"`, default `"never"`. +`"never" | "jsx" | "all"`, default `"never"`. -Qué string literals pueden envolver los fixers, además de los template literals. Con `"jsx"`, el -string propio de un atributo JSX — `className="…"`, o cualquier atributo que el plugin lee — se -envuelve en su lugar en la convención bloque, con la comilla de cierre en la indentación de la línea -del atributo: +Qué string literals pueden envolver los fixers, además de los template literals. Aplican tanto +`wrapLines` como `classesPerLine`, y un archivo con finales de línea CRLF recibe saltos CRLF. + +Con `"jsx"`, el string propio de un atributo JSX — `className="…"`, o cualquier atributo que el +plugin lee — se envuelve en su lugar en la convención bloque, con la comilla de cierre en la +indentación de la línea del atributo: ```tsx
+// Un string de JS con wrapStrings: "all" — se convierte en un template literal +// options: { "classesPerLine": 3, "wrapStrings": "all" } +const card = cn("flex items-center justify-between p-4 m-2 bg-white") +// → const card = cn(` +// flex items-center justify-between +// p-4 m-2 bg-white +// `) + // printWidth: 40 con wrapLines: "overWidth" — SOLO se re-envuelve la // línea que excede; las líneas conformes alrededor quedan tal como estaban // options: { "printWidth": 40, "wrapLines": "overWidth" } diff --git a/packages/docs/es/rules/enforce-consistent-line-wrapping.md b/packages/docs/es/rules/enforce-consistent-line-wrapping.md index 87aa7d5..2223669 100644 --- a/packages/docs/es/rules/enforce-consistent-line-wrapping.md +++ b/packages/docs/es/rules/enforce-consistent-line-wrapping.md @@ -19,8 +19,9 @@ clases por línea, y puede partirlos en varias líneas. Marca strings de clases largos para que no se desparramen más allá de un largo de línea razonable y, cuando lo activas, los envuelve en varias líneas. Dos modos de formateo independientes: un re-wrap basado en el width (opt-in vía `wrapLines`) y un budget de clases por línea (`classesPerLine`), -ambos con autofix en template literals y, con `wrapStrings: "jsx"`, en el string propio de un -atributo JSX (los demás string literals solo reportan — un string de JS no puede cruzar líneas). +ambos con autofix en template literals y, con `wrapStrings`, también en string literals: el string +propio de un atributo JSX en su lugar (`"jsx"`), y un string de JS convertido en template (`"all"`). +Si no, los string literals solo reportan — un string de JS no puede cruzar líneas. DS-opcional — funciona sin `settings.tailwindcss.entryPoint`. La regla opera sobre el string crudo de clases y no le importa qué significan, con una excepción: cuando **sí** hay un design system @@ -81,12 +82,11 @@ alcance como en layout: del budget que no siguen ese layout reportan `inconsistentWrapping`. En un string literal de JS (`cn("…")`, `className={"…"}`, una variable) la regla reporta `tooLong` -sin fix sin importar `wrapLines`: un string de JS no puede contener un salto de línea real, y -convertirlo en un template multilínea es una decisión de criterio (extraer un componente vs. solo -wrappearlo), así que la regla saca el warning y te deja decidir. El string propio de un atributo JSX -sí se puede envolver tal cual — mira `wrapStrings`. Un fragmento de template **pegado** a un `${}` -sin whitespace (`` `${a}flex …` `` — una sola clase en runtime) tampoco se autofixea nunca: -cualquier whitespace introducido en ese borde partiría esa clase en dos. +sin fix por default: un string de JS no puede contener un salto de línea real, y convertirlo en un +template multilínea cambia el valor del string, así que eso es opt-in — mira `wrapStrings`, que +también puede envolver tal cual el string propio de un atributo JSX. Un fragmento de template +**pegado** a un `${}` sin whitespace (`` `${a}flex …` `` — una sola clase en runtime) no se +autofixea nunca: cualquier whitespace introducido en ese borde partiría esa clase en dos. Alrededor de una interpolación, el layout `"all"` pone el run de clases que bordea un `${}` en su **propia línea nueva** (nunca colgando inline después de la expresión), así que toda línea reescrita @@ -139,8 +139,8 @@ líneas que exceden), y el fixer de `classesPerLine` corta por conteo, así que Cantidad máxima de clases en una sola línea. Cuando se excede dentro de un template literal (`` `…` ``), la regla autofixea envolviendo las clases en la convención bloque, en chunks de -`classesPerLine` por línea. Dentro de un string literal de JS la regla reporta `tooManyPerLine` pero -no autofixea; un string de atributo JSX también se envuelve con `wrapStrings: "jsx"`. +`classesPerLine` por línea. Dentro de un string literal la regla reporta `tooManyPerLine` sin fix, a +menos que `wrapStrings` lo cubra. Establecer `classesPerLine` cambia el fixer de template literals a este modo por chunks y apaga el fixer basado en el width (agrupado por variantes) — `printWidth` entonces solo reporta, y @@ -152,12 +152,14 @@ fixer basado en el width (agrupado por variantes) — `printWidth` entonces solo ### `wrapStrings` -`"never" | "jsx"`, default `"never"`. +`"never" | "jsx" | "all"`, default `"never"`. -Qué string literals pueden envolver los fixers, además de los template literals. Con `"jsx"`, el -string propio de un atributo JSX — `className="…"`, o cualquier atributo que el plugin lee — se -envuelve en su lugar en la convención bloque, con la comilla de cierre en la indentación de la línea -del atributo: +Qué string literals pueden envolver los fixers, además de los template literals. Aplican tanto +`wrapLines` como `classesPerLine`, y un archivo con finales de línea CRLF recibe saltos CRLF. + +Con `"jsx"`, el string propio de un atributo JSX — `className="…"`, o cualquier atributo que el +plugin lee — se envuelve en su lugar en la convención bloque, con la comilla de cierre en la +indentación de la línea del atributo: ```tsx
+// Un string de JS con wrapStrings: "all" — se convierte en un template literal +// options: { "classesPerLine": 3, "wrapStrings": "all" } +const card = cn("flex items-center justify-between p-4 m-2 bg-white") +// → const card = cn(` +// flex items-center justify-between +// p-4 m-2 bg-white +// `) + // printWidth: 40 con wrapLines: "overWidth" — SOLO se re-envuelve la // línea que excede; las líneas conformes alrededor quedan tal como estaban // options: { "printWidth": 40, "wrapLines": "overWidth" } diff --git a/packages/docs/interop.md b/packages/docs/interop.md index ec047cf..27c9af0 100644 --- a/packages/docs/interop.md +++ b/packages/docs/interop.md @@ -55,9 +55,10 @@ The formatter also drops duplicate classes and collapses whitespace in the strin overlaps with `no-duplicate-classes` and `no-unnecessary-whitespace` there; the lint rules keep covering everything else. -That includes the line breaks of a class attribute that `enforce-consistent-line-wrapping` wrapped -into a block (`wrapStrings: "jsx"`): the formatter puts it back on one line, and each undoes the -other. Set `preserveWhitespace: true` in `sortTailwindcss` to keep the block. +That includes the line breaks of a class string that `enforce-consistent-line-wrapping` wrapped into +a block, whether a class attribute (`wrapStrings: "jsx"`) or a template in a helper the formatter +sorts (`cn(…)` with `wrapStrings: "all"`): the formatter puts it back on one line, and each undoes +the other. Set `preserveWhitespace: true` in `sortTailwindcss` to keep the block. ::: tip `--fix` more than once diff --git a/packages/docs/migration/from-better-tailwindcss.md b/packages/docs/migration/from-better-tailwindcss.md index 7a1ffa4..78e0366 100644 --- a/packages/docs/migration/from-better-tailwindcss.md +++ b/packages/docs/migration/from-better-tailwindcss.md @@ -14,23 +14,23 @@ example — the tables below are checked against it every week, with its latest -| better-tailwindcss | oxlint-tailwindcss | Notes | -| --------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enforce-consistent-class-order` | [`enforce-sort-order`](/rules/enforce-sort-order) | Their default `order: "official"` is this rule's order. Unknown classes go first, in the order written, as with their `unknownClassOrder: "preserve"`. `order: "strict"` → `mode: "strict"`, which also groups classes by variant chain (the order within can differ). `asc`, `desc` and the `componentClass*` / `unknownClass*` options have no equivalent. | -| `enforce-consistent-important-position` | [`enforce-consistent-important-position`](/rules/enforce-consistent-important-position) | `position: "recommended"` → `"suffix"` (the default, `p-4!`); `"legacy"` → `"prefix"` (`!p-4`). | -| `enforce-consistent-line-wrapping` | [`enforce-consistent-line-wrapping`](/rules/enforce-consistent-line-wrapping) | `printWidth`, `classesPerLine` and `group` mean the same. The autofix rewrites template literals, and a JSX string attribute only with `wrapStrings: "jsx"` (otherwise it is reported, not rewritten); a line over `printWidth` is rewrapped only with `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` and `vueConvertToBinding` have no equivalent: the indentation and line endings come from the source. | -| `enforce-consistent-variant-order` | [`consistent-variant-order`](/rules/consistent-variant-order) | Both write chains outermost first (`sm:hover:`). This rule also orders color scheme and attribute variants (`dark:hover:`, `data-[state=open]:hover:`), which theirs leaves alone; `order` sets your own. | -| `enforce-consistent-variable-syntax` | [`enforce-consistent-variable-syntax`](/rules/enforce-consistent-variable-syntax) | `syntax: "shorthand"` is the default in both; `"variable"` → `"explicit"`. | -| `enforce-shorthand-classes` | [`enforce-shorthand`](/rules/enforce-shorthand) | | -| `enforce-logical-properties` | [`enforce-logical`](/rules/enforce-logical) | `ignore` → `allowlist` (regular expressions, too). The block axis (`mt-*` → `mbs-*`, `top-*` → `inset-bs-*`) is on by default; widths and heights (`w-*` → `inline-*`, `size-*` → `inline-* block-*`) need `sizing: true`. `direction` limits it to inline or block utilities; `enforce-physical` is the reverse rule. | -| `enforce-canonical-classes` | [`enforce-canonical`](/rules/enforce-canonical) | Their `collapse` (merging `mt-2 mb-2` into `my-2`) is `enforce-shorthand` here. `ignore` and `logical` have no equivalent. | -| `no-duplicate-classes` | [`no-duplicate-classes`](/rules/no-duplicate-classes) | | -| `no-deprecated-classes` | [`no-deprecated-classes`](/rules/no-deprecated-classes) | | -| `no-unnecessary-whitespace` | [`no-unnecessary-whitespace`](/rules/no-unnecessary-whitespace) | Line breaks are always kept, as with their `allowMultiline: true`. | -| `no-concatenated-classes` | [`no-dynamic-classes`](/rules/no-dynamic-classes) | Both report a class built with `${}` or `+`. This one reports it when the built class starts with a Tailwind utility or variant, so `"p-4 " + extra` and `` `icon-${name}` `` are fine. | -| `no-unknown-classes` | [`no-unknown-classes`](/rules/no-unknown-classes) | `ignore` (regular expressions) → `allowlist` (exact names) and `ignorePrefixes`. The classes your CSS defines (`@layer components`, `@utility`) are always known, so their `detectComponentClasses` setting has no counterpart. | -| `no-conflicting-classes` | [`no-conflicting-classes`](/rules/no-conflicting-classes) | It also reports a class another one makes redundant (`reportRedundant`), and `allow` exempts pairs. | -| `no-restricted-classes` | [`no-restricted-classes`](/rules/no-restricted-classes) | `restrict: [{ pattern, message }]` → `patterns: [{ pattern, message }]`, and `classes` for exact names. There is no `fix`. | +| better-tailwindcss | oxlint-tailwindcss | Notes | +| --------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enforce-consistent-class-order` | [`enforce-sort-order`](/rules/enforce-sort-order) | Their default `order: "official"` is this rule's order. Unknown classes go first, in the order written, as with their `unknownClassOrder: "preserve"`. `order: "strict"` → `mode: "strict"`, which also groups classes by variant chain (the order within can differ). `asc`, `desc` and the `componentClass*` / `unknownClass*` options have no equivalent. | +| `enforce-consistent-important-position` | [`enforce-consistent-important-position`](/rules/enforce-consistent-important-position) | `position: "recommended"` → `"suffix"` (the default, `p-4!`); `"legacy"` → `"prefix"` (`!p-4`). | +| `enforce-consistent-line-wrapping` | [`enforce-consistent-line-wrapping`](/rules/enforce-consistent-line-wrapping) | `printWidth`, `classesPerLine` and `group` mean the same. The autofix rewrites template literals; with `wrapStrings: "all"` it wraps strings the way theirs does, a JSX string attribute in place and a JS string turned into a template literal (`"jsx"` wraps only the former, and otherwise a string is reported, not rewritten). A line over `printWidth` is rewrapped only with `wrapLines`. `indent`, `tabWidth`, `lineBreakStyle`, `preferSingleLine`, `strictness` and `vueConvertToBinding` have no equivalent: the indentation and line endings come from the source. | +| `enforce-consistent-variant-order` | [`consistent-variant-order`](/rules/consistent-variant-order) | Both write chains outermost first (`sm:hover:`). This rule also orders color scheme and attribute variants (`dark:hover:`, `data-[state=open]:hover:`), which theirs leaves alone; `order` sets your own. | +| `enforce-consistent-variable-syntax` | [`enforce-consistent-variable-syntax`](/rules/enforce-consistent-variable-syntax) | `syntax: "shorthand"` is the default in both; `"variable"` → `"explicit"`. | +| `enforce-shorthand-classes` | [`enforce-shorthand`](/rules/enforce-shorthand) | | +| `enforce-logical-properties` | [`enforce-logical`](/rules/enforce-logical) | `ignore` → `allowlist` (regular expressions, too). The block axis (`mt-*` → `mbs-*`, `top-*` → `inset-bs-*`) is on by default; widths and heights (`w-*` → `inline-*`, `size-*` → `inline-* block-*`) need `sizing: true`. `direction` limits it to inline or block utilities; `enforce-physical` is the reverse rule. | +| `enforce-canonical-classes` | [`enforce-canonical`](/rules/enforce-canonical) | Their `collapse` (merging `mt-2 mb-2` into `my-2`) is `enforce-shorthand` here. `ignore` and `logical` have no equivalent. | +| `no-duplicate-classes` | [`no-duplicate-classes`](/rules/no-duplicate-classes) | | +| `no-deprecated-classes` | [`no-deprecated-classes`](/rules/no-deprecated-classes) | | +| `no-unnecessary-whitespace` | [`no-unnecessary-whitespace`](/rules/no-unnecessary-whitespace) | Line breaks are always kept, as with their `allowMultiline: true`. | +| `no-concatenated-classes` | [`no-dynamic-classes`](/rules/no-dynamic-classes) | Both report a class built with `${}` or `+`. This one reports it when the built class starts with a Tailwind utility or variant, so `"p-4 " + extra` and `` `icon-${name}` `` are fine. | +| `no-unknown-classes` | [`no-unknown-classes`](/rules/no-unknown-classes) | `ignore` (regular expressions) → `allowlist` (exact names) and `ignorePrefixes`. The classes your CSS defines (`@layer components`, `@utility`) are always known, so their `detectComponentClasses` setting has no counterpart. | +| `no-conflicting-classes` | [`no-conflicting-classes`](/rules/no-conflicting-classes) | It also reports a class another one makes redundant (`reportRedundant`), and `allow` exempts pairs. | +| `no-restricted-classes` | [`no-restricted-classes`](/rules/no-restricted-classes) | `restrict: [{ pattern, message }]` → `patterns: [{ pattern, message }]`, and `classes` for exact names. There is no `fix`. | diff --git a/packages/docs/rules/_extras/enforce-consistent-line-wrapping.md b/packages/docs/rules/_extras/enforce-consistent-line-wrapping.md index a5344f8..65d3ed1 100644 --- a/packages/docs/rules/_extras/enforce-consistent-line-wrapping.md +++ b/packages/docs/rules/_extras/enforce-consistent-line-wrapping.md @@ -7,8 +7,9 @@ description: "oxlint rule that reports Tailwind CSS class strings over a print w Flags long class strings so they don't sprawl past a sensible line length and, when you opt in, wraps them into multiple lines. Two independent formatting modes: a width-based re-wrap (opt-in via `wrapLines`) and a class-count budget per line (`classesPerLine`), both autofixing template literals -and, with `wrapStrings: "jsx"`, a JSX attribute's own string (other string literals only report — a -JS string can't span lines). +and, with `wrapStrings`, string literals too: a JSX attribute's own string in place (`"jsx"`), and a +JS string turned into a template (`"all"`). Otherwise string literals only report — a JS string +can't span lines. DS-optional — works without `settings.tailwindcss.entryPoint`. The rule operates on the raw class string and doesn't care what the classes mean, with one exception: when a design system **is** @@ -67,12 +68,11 @@ layout: `inconsistentWrapping`. In a JS string literal (`cn("…")`, `className={"…"}`, a variable) the rule reports `tooLong` without -a fix regardless of `wrapLines`: a JS string can't hold a raw newline, and turning it into a -multiline template is a judgment call (extract a component vs. just wrap it), so the rule surfaces -the warning and lets you decide. A JSX attribute's own string can be wrapped as it is — see -`wrapStrings`. A template fragment **glued** to a `${}` with no whitespace (`` `${a}flex …` `` — one -runtime class) is also never autofixed: any whitespace introduced at the boundary would split that -class in two. +a fix by default: a JS string can't hold a raw newline, and turning it into a multiline template +changes the string's value, so that is opt-in — see `wrapStrings`, which can also wrap a JSX +attribute's own string as it is. A template fragment **glued** to a `${}` with no whitespace +(`` `${a}flex …` `` — one runtime class) is never autofixed: any whitespace introduced at the +boundary would split that class in two. Around an interpolation, the `"all"` layout puts the class run bordering a `${}` on its **own fresh line** (never hanging inline after the expression), so every rewritten line stays within @@ -124,8 +124,8 @@ over-budget lines), and the `classesPerLine` fixer chunks by count, so both igno Maximum number of classes on a single line. When exceeded inside a template literal (`` `…` ``), the rule autofixes by wrapping the classes into the block convention, chunks of `classesPerLine` per -line. Inside a JS string literal the rule reports `tooManyPerLine` but doesn't autofix; a JSX -attribute string is wrapped too with `wrapStrings: "jsx"`. +line. Inside a string literal the rule reports `tooManyPerLine` without a fix, unless `wrapStrings` +covers it. Setting `classesPerLine` switches the template-literal fixer to this chunk-based mode and turns the width-based (variant-grouped) fixer off — `printWidth` then only reports, and `wrapLines` is @@ -137,11 +137,13 @@ ignored. ### `wrapStrings` -`"never" | "jsx"`, default `"never"`. +`"never" | "jsx" | "all"`, default `"never"`. -Which string literals the fixers may wrap, besides template literals. With `"jsx"`, a JSX -attribute's own string — `className="…"`, or any attribute the plugin reads — is wrapped in place -into the block convention, the closing quote at the attribute line's indentation: +Which string literals the fixers may wrap, besides template literals. Both `wrapLines` and +`classesPerLine` apply, and a file with CRLF line endings gets CRLF breaks. + +With `"jsx"`, a JSX attribute's own string — `className="…"`, or any attribute the plugin reads — is +wrapped in place into the block convention, the closing quote at the attribute line's indentation: ```tsx
+// A JS string with wrapStrings: "all" — turned into a template literal +// options: { "classesPerLine": 3, "wrapStrings": "all" } +const card = cn("flex items-center justify-between p-4 m-2 bg-white") +// → const card = cn(` +// flex items-center justify-between +// p-4 m-2 bg-white +// `) + // printWidth: 40 with wrapLines: "overWidth" — ONLY the over-budget // line is re-wrapped; the conforming lines around it stay exactly as written // options: { "printWidth": 40, "wrapLines": "overWidth" } diff --git a/packages/docs/rules/enforce-consistent-line-wrapping.md b/packages/docs/rules/enforce-consistent-line-wrapping.md index 4e59fdd..af08e48 100644 --- a/packages/docs/rules/enforce-consistent-line-wrapping.md +++ b/packages/docs/rules/enforce-consistent-line-wrapping.md @@ -19,8 +19,9 @@ and can wrap them onto several lines. Flags long class strings so they don't sprawl past a sensible line length and, when you opt in, wraps them into multiple lines. Two independent formatting modes: a width-based re-wrap (opt-in via `wrapLines`) and a class-count budget per line (`classesPerLine`), both autofixing template literals -and, with `wrapStrings: "jsx"`, a JSX attribute's own string (other string literals only report — a -JS string can't span lines). +and, with `wrapStrings`, string literals too: a JSX attribute's own string in place (`"jsx"`), and a +JS string turned into a template (`"all"`). Otherwise string literals only report — a JS string +can't span lines. DS-optional — works without `settings.tailwindcss.entryPoint`. The rule operates on the raw class string and doesn't care what the classes mean, with one exception: when a design system **is** @@ -79,12 +80,11 @@ layout: `inconsistentWrapping`. In a JS string literal (`cn("…")`, `className={"…"}`, a variable) the rule reports `tooLong` without -a fix regardless of `wrapLines`: a JS string can't hold a raw newline, and turning it into a -multiline template is a judgment call (extract a component vs. just wrap it), so the rule surfaces -the warning and lets you decide. A JSX attribute's own string can be wrapped as it is — see -`wrapStrings`. A template fragment **glued** to a `${}` with no whitespace (`` `${a}flex …` `` — one -runtime class) is also never autofixed: any whitespace introduced at the boundary would split that -class in two. +a fix by default: a JS string can't hold a raw newline, and turning it into a multiline template +changes the string's value, so that is opt-in — see `wrapStrings`, which can also wrap a JSX +attribute's own string as it is. A template fragment **glued** to a `${}` with no whitespace +(`` `${a}flex …` `` — one runtime class) is never autofixed: any whitespace introduced at the +boundary would split that class in two. Around an interpolation, the `"all"` layout puts the class run bordering a `${}` on its **own fresh line** (never hanging inline after the expression), so every rewritten line stays within @@ -136,8 +136,8 @@ over-budget lines), and the `classesPerLine` fixer chunks by count, so both igno Maximum number of classes on a single line. When exceeded inside a template literal (`` `…` ``), the rule autofixes by wrapping the classes into the block convention, chunks of `classesPerLine` per -line. Inside a JS string literal the rule reports `tooManyPerLine` but doesn't autofix; a JSX -attribute string is wrapped too with `wrapStrings: "jsx"`. +line. Inside a string literal the rule reports `tooManyPerLine` without a fix, unless `wrapStrings` +covers it. Setting `classesPerLine` switches the template-literal fixer to this chunk-based mode and turns the width-based (variant-grouped) fixer off — `printWidth` then only reports, and `wrapLines` is @@ -149,11 +149,13 @@ ignored. ### `wrapStrings` -`"never" | "jsx"`, default `"never"`. +`"never" | "jsx" | "all"`, default `"never"`. -Which string literals the fixers may wrap, besides template literals. With `"jsx"`, a JSX -attribute's own string — `className="…"`, or any attribute the plugin reads — is wrapped in place -into the block convention, the closing quote at the attribute line's indentation: +Which string literals the fixers may wrap, besides template literals. Both `wrapLines` and +`classesPerLine` apply, and a file with CRLF line endings gets CRLF breaks. + +With `"jsx"`, a JSX attribute's own string — `className="…"`, or any attribute the plugin reads — is +wrapped in place into the block convention, the closing quote at the attribute line's indentation: ```tsx
+// A JS string with wrapStrings: "all" — turned into a template literal +// options: { "classesPerLine": 3, "wrapStrings": "all" } +const card = cn("flex items-center justify-between p-4 m-2 bg-white") +// → const card = cn(` +// flex items-center justify-between +// p-4 m-2 bg-white +// `) + // printWidth: 40 with wrapLines: "overWidth" — ONLY the over-budget // line is re-wrapped; the conforming lines around it stay exactly as written // options: { "printWidth": 40, "wrapLines": "overWidth" } diff --git a/packages/oxlint-tailwindcss/CHANGELOG.md b/packages/oxlint-tailwindcss/CHANGELOG.md index 13e5230..df7f8c7 100644 --- a/packages/oxlint-tailwindcss/CHANGELOG.md +++ b/packages/oxlint-tailwindcss/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **`enforce-consistent-line-wrapping` wraps JS strings too, with `wrapStrings: "all"` (#216).** A + class string that has to wrap in `cn("…")`, `className={"…"}`, an array, a `cva` / `tv` value, a + ternary or `&&` branch, or a variable's value becomes a template literal with the block layout, as + eslint-plugin-better-tailwindcss does, and a JSX attribute's own string is still wrapped in place. + An object key, a `+` operand, and a string holding an escape sequence, a backtick or `${` keep + reporting without a fix. It's opt-in because the string's value gains the newlines and the + indentation; `"jsx"` and the default `"never"` are unchanged. + ## 1.15.1 `enforce-canonical` rewrites a class only when its canonical form prints the same CSS, and it diff --git a/packages/oxlint-tailwindcss/src/rules/enforce-consistent-line-wrapping.ts b/packages/oxlint-tailwindcss/src/rules/enforce-consistent-line-wrapping.ts index fecb494..6defc2a 100644 --- a/packages/oxlint-tailwindcss/src/rules/enforce-consistent-line-wrapping.ts +++ b/packages/oxlint-tailwindcss/src/rules/enforce-consistent-line-wrapping.ts @@ -54,13 +54,18 @@ type GroupMode = 'newLine' | 'emptyLine' | 'never' /** * Which string literals the fixers may wrap, besides template literals (#216): * - 'never' — none. The default: a JS string can't hold a raw newline, and - * turning it into a template is a call the rule doesn't make. + * turning it into a template changes its value. * - 'jsx' — a JSX attribute's own string, `className="…"`, which CAN span * lines as written: in a class attribute the newlines are plain * whitespace, so nothing is converted. Never `className={"…"}`, * `cn("…")` or a variable, which are JS strings. + * - 'all' — that, and a JS string turned into a template literal, as + * eslint-plugin-better-tailwindcss does (see + * `isConvertibleJsString`). The string's value gains the newlines + * and indentation: whitespace in a class list, but not where the + * string is used as something else, hence opt-in. */ -type WrapStringsMode = 'never' | 'jsx' +type WrapStringsMode = 'never' | 'jsx' | 'all' interface Options { entryPoint?: string @@ -81,6 +86,46 @@ function isJsxAttributeString(loc: ClassLocation): boolean { return node.type === 'Literal' && node.parent?.type === 'JSXAttribute' } +interface ParentNode { + type?: string + arguments?: unknown[] + value?: unknown + init?: unknown + test?: unknown +} + +/** + * A JS string `wrapStrings: 'all'` turns into a template literal. Its place + * takes any expression — a call argument (`cn("…")`), `className={"…"}`, an + * array element, an object value, a ternary or `&&` branch, a variable's + * initializer — but never an object key or a `+` operand. And a template reads + * its text the same way: `raw` is the source between the quotes, so it must + * equal the value (no escape sequence, which a template can read differently) + * and hold no `` ` `` or `${`. + */ +function isConvertibleJsString(loc: ClassLocation, raw: string | undefined): boolean { + const node = loc.node as { type: string; parent?: ParentNode } + if (node.type !== 'Literal' || raw !== loc.value) return false + if (raw.includes('`') || raw.includes('${')) return false + const parent = node.parent + switch (parent?.type) { + case 'CallExpression': + return parent.arguments?.includes(node) === true + case 'Property': + return parent.value === node + case 'VariableDeclarator': + return parent.init === node + case 'ConditionalExpression': + return parent.test !== node + case 'ArrayExpression': + case 'LogicalExpression': + case 'JSXExpressionContainer': + return true + default: + return false + } +} + /** One indentation level added below the statement's base indent for wrapped lines. */ const INDENT_UNIT = ' ' @@ -181,7 +226,9 @@ export const enforceConsistentLineWrapping = defineRule({ recommended: false, designSystem: 'optional', }), - fixable: 'whitespace', + // 'code': with `wrapStrings: 'all'` a fix also swaps a string's quotes for + // backticks. + fixable: 'code', schema: [ { type: 'object', @@ -191,7 +238,7 @@ export const enforceConsistentLineWrapping = defineRule({ classesPerLine: { type: 'number' }, wrapLines: { type: 'string', enum: ['overWidth', 'all'] }, group: { type: 'string', enum: ['newLine', 'emptyLine', 'never'] }, - wrapStrings: { type: 'string', enum: ['never', 'jsx'] }, + wrapStrings: { type: 'string', enum: ['never', 'jsx', 'all'] }, }, additionalProperties: false, }, @@ -256,10 +303,10 @@ export const enforceConsistentLineWrapping = defineRule({ } /** - * The line ending a JSX string's fix writes. A JSX attribute keeps the - * file's `\r\n` in its value (a template's is LF already), so a CRLF file - * gets CRLF breaks: from the value when it has any, else from the line the - * string ends on. + * The line ending a string's fix writes. A JSX attribute keeps the file's + * `\r\n` in its value (a template's is LF already), so a CRLF file gets + * CRLF breaks: from the value when it has any, else from the line the + * string ends on — the only source for a JS string turned into a template. */ function lineEndingOf(loc: ClassLocation): string { if (loc.value.includes('\r\n')) return '\r\n' @@ -401,17 +448,27 @@ export const enforceConsistentLineWrapping = defineRule({ : '' const wrapStrings = getWrapStrings() + const text = wrapStrings === 'all' ? safeSourceCode(context)?.text : undefined for (const written of locations) { - // A template's quasis, and with `wrapStrings: 'jsx'` a JSX attribute's - // own string (#216); every other string literal only reports. - const wrapsString = wrapStrings === 'jsx' && isJsxAttributeString(written) - const fixable = written.node.type === 'TemplateElement' || wrapsString + // A template's quasis; with `wrapStrings` 'jsx' or 'all' a JSX + // attribute's own string, wrapped in place (#216); with 'all' a JS + // string, turned into a template. Every other string only reports. + const wrapsString = wrapStrings !== 'never' && isJsxAttributeString(written) + const convertsString = + wrapStrings === 'all' && + !wrapsString && + isConvertibleJsString(written, text?.slice(written.range[0], written.range[1])) + const fixable = written.node.type === 'TemplateElement' || wrapsString || convertsString // Measured and wrapped in LF, written back in the file's line ending. - const eol = wrapsString ? lineEndingOf(written) : '\n' + const eol = wrapsString || convertsString ? lineEndingOf(written) : '\n' const loc: ClassLocation = eol === '\n' ? written : { ...written, value: written.value.replace(/\r\n/g, '\n') } const toSource = (value: string) => (eol === '\n' ? value : value.replace(/\n/g, eol)) + // A converted string's fix replaces its quotes too, with backticks. + const fixRange: [number, number] = convertsString ? loc.node.range : loc.range + const toFix = (value: string) => + convertsString ? '`' + toSource(value) + '`' : toSource(value) // #110: measure the LONGEST INDIVIDUAL LINE, not the raw total. The raw // value of a multiline template includes every `\n` and indent, so // comparing its total length made wrapping impossible to satisfy. @@ -455,7 +512,7 @@ export const enforceConsistentLineWrapping = defineRule({ messageId: maxLine > printWidth ? 'tooLong' : 'inconsistentWrapping', data, fix(fixer) { - return fixer.replaceTextRange(loc.range, toSource(fixedValue)) + return fixer.replaceTextRange(fixRange, toFix(fixedValue)) }, }) } @@ -485,10 +542,7 @@ export const enforceConsistentLineWrapping = defineRule({ messageId: 'tooManyPerLine', data, fix(fixer) { - return fixer.replaceTextRange( - loc.range, - toSource(preserveSpaces(loc, fixedValue)), - ) + return fixer.replaceTextRange(fixRange, toFix(preserveSpaces(loc, fixedValue))) }, }) } else { diff --git a/packages/oxlint-tailwindcss/tests/e2e/oxfmt-interop.test.ts b/packages/oxlint-tailwindcss/tests/e2e/oxfmt-interop.test.ts index a12f779..bf28f3d 100644 --- a/packages/oxlint-tailwindcss/tests/e2e/oxfmt-interop.test.ts +++ b/packages/oxlint-tailwindcss/tests/e2e/oxfmt-interop.test.ts @@ -18,8 +18,9 @@ import { assertFreshDist, DIST_CJS } from './helpers/dist' // - without `stylesheet`, oxfmt doesn't know the project's tokens and orders // them differently; // - a JSX class string wrapped into a block (`enforce-consistent-line-wrapping` -// with `wrapStrings: 'jsx'`) is collapsed back to one line by oxfmt unless -// `preserveWhitespace: true`; +// with `wrapStrings: 'jsx'`), and a template block in a helper (what +// `wrapStrings: 'all'` turns a JS string into), are collapsed back to one +// line by oxfmt unless `preserveWhitespace: true`; // - oxfmt leaves the variant chain inside a class as written, so // `consistent-variant-order` has no formatter counterpart (its rule page // says so). @@ -130,6 +131,22 @@ describe('E2E: oxfmt sortTailwindcss and oxlint-tailwindcss agree', () => { expect(read(kept)).toBe(block) }) + it('a template block in a helper survives oxfmt only with preserveWhitespace', () => { + // What `wrapStrings: 'all'` turns `cn("…")` into. + const block = + 'import { cn } from "./cn";\n\nexport const b = cn(`\n flex items-center justify-between\n p-4 text-sm\n`);\n' + const collapsed = project({ stylesheet: './src/app.css', functions: ['cn'] }, block) + run(OXFMT, ['--write', 'src/a.tsx'], collapsed) + expect(read(collapsed)).toContain('cn(`flex items-center justify-between p-4 text-sm`)') + + const kept = project( + { stylesheet: './src/app.css', functions: ['cn'], preserveWhitespace: true }, + block, + ) + run(OXFMT, ['--write', 'src/a.tsx'], kept) + expect(read(kept)).toBe(block) + }) + it('CANARY: oxlint applies one fix per class string per run', () => { // Three rules fix the same string (duplicate, whitespace, order) and oxlint // has no multi-pass fixing: one run applies one of them. interop.md says so. diff --git a/packages/oxlint-tailwindcss/tests/integration/whitespace-line-wrap-cycle.test.ts b/packages/oxlint-tailwindcss/tests/integration/whitespace-line-wrap-cycle.test.ts index ca6b7c5..9b0d639 100644 --- a/packages/oxlint-tailwindcss/tests/integration/whitespace-line-wrap-cycle.test.ts +++ b/packages/oxlint-tailwindcss/tests/integration/whitespace-line-wrap-cycle.test.ts @@ -205,4 +205,47 @@ describe('issue #14 — whitespace × line-wrapping no longer cycle', () => { invalid: [], }, ) + + // Step 11: a JS string turned into a template (`wrapStrings: 'all'`): the + // block lands between backticks, the closing one on its indented line. + const jsString = + 'function C() {\n return cn("bg-red-500 text-white hover:bg-red-600 focus:ring-2")\n}' + const jsBlock = + 'function C() {\n return cn(`\n bg-red-500 text-white hover:bg-red-600\n focus:ring-2\n `)\n}' + new RuleTester().run('step 11: JS string line-wrapping autofix', enforceConsistentLineWrapping, { + valid: [], + invalid: [ + { + code: jsString, + filename: 'a.tsx', + options: [{ printWidth: 200, classesPerLine: 3, wrapStrings: 'all' }], + errors: [{ messageId: 'tooManyPerLine' }], + output: jsBlock, + }, + ], + }) + + // Step 12: whitespace stays silent on it, and so does line-wrapping itself. + new RuleTester().run( + 'step 12: whitespace stays silent on the converted block', + noUnnecessaryWhitespace, + { + valid: [{ code: jsBlock, filename: 'a.tsx' }], + invalid: [], + }, + ) + new RuleTester().run( + 'step 12: line-wrapping stays silent on the converted block', + enforceConsistentLineWrapping, + { + valid: [ + { + code: jsBlock, + filename: 'a.tsx', + options: [{ printWidth: 200, classesPerLine: 3, wrapStrings: 'all' }], + }, + ], + invalid: [], + }, + ) }) diff --git a/packages/oxlint-tailwindcss/tests/rules/enforce-consistent-line-wrapping.test.ts b/packages/oxlint-tailwindcss/tests/rules/enforce-consistent-line-wrapping.test.ts index 78e3c32..ab8abab 100644 --- a/packages/oxlint-tailwindcss/tests/rules/enforce-consistent-line-wrapping.test.ts +++ b/packages/oxlint-tailwindcss/tests/rules/enforce-consistent-line-wrapping.test.ts @@ -829,3 +829,95 @@ ruleTester.run( ], }, ) + +/** + * `wrapStrings: 'all'`: a JS string is turned into a template literal with the + * block layout, as eslint-plugin-better-tailwindcss does — wherever its place + * takes any expression. Never an object key, a `+` operand, or a string a + * template would read differently (an escape sequence, a backtick, `${`). A + * JSX attribute's own string is still wrapped in place. + */ +const ALL = { classesPerLine: 2, wrapStrings: 'all' as const } +const converted = (code: string, output: string) => ({ + code, + filename: 'test.tsx', + options: [ALL], + errors: [{ messageId: 'tooManyPerLine' as const }], + output, +}) +const reportedOnly = (code: string) => ({ + code, + filename: 'test.tsx', + options: [ALL], + errors: [{ messageId: 'tooManyPerLine' as const }], +}) + +ruleTester.run( + 'enforce-consistent-line-wrapping (wrapStrings: all)', + enforceConsistentLineWrapping, + { + valid: [ + // What the fix writes: within the budget, nothing to do. + { code: 'cn(`\n a b\n c d\n`)', filename: 'test.tsx', options: [ALL] }, + // Short enough: never converted. + { code: 'cn("a b")', filename: 'test.tsx', options: [ALL] }, + ], + invalid: [ + // Every place that takes any expression. + converted('cn("a b c d")', 'cn(`\n a b\n c d\n`)'), + converted("cn('a b c d')", 'cn(`\n a b\n c d\n`)'), + converted('
', '
'), + converted('const className = "a b c d"', 'const className = `\n a b\n c d\n`'), + converted( + '
', + '
', + ), + converted('cn(on && "a b c d")', 'cn(on && `\n a b\n c d\n`)'), + converted('cn(["a b c d"])', 'cn([`\n a b\n c d\n`])'), + { + ...converted( + 'cva("a b c d", { variants: { size: { sm: "e f g h" } } })', + 'cva(`\n a b\n c d\n`, { variants: { size: { sm: `\n e f\n g h\n` } } })', + ), + errors: [{ messageId: 'tooManyPerLine' }, { messageId: 'tooManyPerLine' }], + }, + { + ...converted( + '
', + '
', + ), + settings: { tailwindcss: { attributes: ['classNames'] } }, + }, + // A JSX attribute's own string is still wrapped in place. + converted('
', '
'), + // The base indent is the string's line, and a CRLF file gets CRLF breaks. + converted( + 'function C() {\n return cn("a b c d")\n}', + 'function C() {\n return cn(`\n a b\n c d\n `)\n}', + ), + converted('cn("a b c d")\r\n', 'cn(`\r\n a b\r\n c d\r\n`)\r\n'), + // The width fixers convert too. + { + code: 'cn("flex items-center justify-between p-4 m-2")', + filename: 'test.tsx', + options: [{ printWidth: 40, wrapLines: 'overWidth', wrapStrings: 'all' }], + errors: [{ messageId: 'tooLong' }], + output: 'cn(`\n flex items-center justify-between p-4\n m-2\n`)', + }, + { + code: 'cn("flex gap-2 hover:underline hover:bg-red-500")', + filename: 'test.tsx', + options: [{ printWidth: 40, wrapLines: 'all', wrapStrings: 'all' }], + errors: [{ messageId: 'tooLong' }], + output: 'cn(`\n flex gap-2\n hover:underline hover:bg-red-500\n`)', + }, + // Report only: an object key, a `+` operand, and strings a template would + // read differently. + reportedOnly('cn({ "a b c d": on })'), + reportedOnly('cn("a b c d " + x)'), + reportedOnly('cn("a b c \\u0064")'), + reportedOnly('cn("a b c content-[\'`\']")'), + reportedOnly('cn("a b c ${d}")'), + ], + }, +)