diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adb296d..cbeac05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Add new `tailwindPreserveDuplicates` option to disable removal of duplicate classes ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) + +### Fixed + +- Improve handling of whitespace removal when concatenating strings ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) +- Fix a bug where Angular expressions may produce invalid code after sorting ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) +- Disabled whitespace and duplicate class removal for Liquid and Svelte ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) ## [0.6.0] - 2024-05-30 diff --git a/README.md b/README.md index 86f31013..91be4779 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,57 @@ Once added, tag your strings with the function and the plugin will sort them: const mySortedClasses = tw`bg-white p-4 dark:bg-black` ``` +## Preserving whitespace + +This plugin automatically removes unnecessary whitespace between classes to ensure consistent formatting. If you prefer to preserve whitespace, you can use the `tailwindPreserveWhitespace` option: + +```json5 +// .prettierrc +{ + "tailwindPreserveWhitespace": true, +} +``` + +With this configuration, any whitespace surrounding classes will be preserved: + +```jsx +import clsx from 'clsx' + +function MyButton({ isHovering, children }) { + return ( + + ) +} +``` + +## Preserving duplicate classes + +This plugin automatically removes duplicate classes from your class lists. However, this can cause issues in some templating languages, like Fluid or Blade, where we can't distinguish between classes and the templating syntax. + +If removing duplicate classes is causing issues in your project, you can use the `tailwindPreserveDuplicates` option to disable this behavior: + +```json5 +// .prettierrc +{ + "tailwindPreserveDuplicates": true, +} +``` + +With this configuration, anything we perceive as duplicate classes will be preserved: + +```html +
+
+``` + ## Compatibility with other Prettier plugins This plugin uses Prettier APIs that can only be used by one plugin at a time, making it incompatible with other Prettier plugins implemented the same way. To solve this we've added explicit per-plugin workarounds that enable compatibility with the following Prettier plugins: diff --git a/src/index.js b/src/index.js index 0f1c31d3..9b9c4332 100644 --- a/src/index.js +++ b/src/index.js @@ -11,13 +11,14 @@ import { getTailwindConfig } from './config.js' import { getCustomizations } from './options.js' import { loadPlugins } from './plugins.js' import { sortClasses, sortClassList } from './sorting.js' -import { visit } from './utils.js' +import { spliceChangesIntoString, visit } from './utils.js' let base = await loadPlugins() /** @typedef {import('./types.js').Customizations} Customizations */ /** @typedef {import('./types.js').TransformerContext} TransformerContext */ /** @typedef {import('./types.js').TransformerMetadata} TransformerMetadata */ +/** @typedef {import('./types.js').StringChange} StringChange */ /** * @param {string} parserFormat @@ -113,15 +114,31 @@ function transformDynamicAngularAttribute(attr, env) { return } + let changes = [] + visit(directiveAst, { - StringLiteral(node) { + StringLiteral(node, parent, key) { if (!node.value) return - attr.value = - attr.value.slice(0, node.start + 1) + - sortClasses(node.value, { env }) + - attr.value.slice(node.end - 1) + + let isConcat = + parent.type === 'BinaryExpression' && parent.operator === '+' + + changes.push({ + start: node.start + 1, + end: node.end - 1, + before: node.value, + after: sortClasses(node.value, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }), + }) }, }) + + attr.value = spliceChangesIntoString(attr.value, changes) } function transformDynamicJsAttribute(attr, env) { @@ -135,8 +152,21 @@ function transformDynamicJsAttribute(attr, env) { astTypes.visit(ast, { visitLiteral(path) { + let isConcat = + path.parent.value.type === 'BinaryExpression' && + path.parent.value.operator === '+' + let key = path.name + if (isStringLiteral(path.node)) { - if (sortStringLiteral(path.node, { env })) { + let sorted = sortStringLiteral(path.node, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) + + if (sorted) { didChange = true // https://github.com/benjamn/recast/issues/171#issuecomment-224996336 @@ -153,18 +183,45 @@ function transformDynamicJsAttribute(attr, env) { }, visitTemplateLiteral(path) { - if (sortTemplateLiteral(path.node, { env })) { + let isConcat = + path.parent.value.type === 'BinaryExpression' && + path.parent.value.operator === '+' + let key = path.name + let sorted = sortTemplateLiteral(path.node, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) + + if (sorted) { didChange = true } + this.traverse(path) }, visitTaggedTemplateExpression(path) { + let isConcat = + path.parent.value.type === 'BinaryExpression' && + path.parent.value.operator === '+' + let key = path.name + if (isSortableTemplateExpression(path.node, functions)) { - if (sortTemplateLiteral(path.node.quasi, { env })) { + let sorted = sortTemplateLiteral(path.node.quasi, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) + + if (sorted) { didChange = true } } + this.traverse(path) }, }) @@ -290,7 +347,7 @@ function transformLiquid(ast, { env }) { /** @type {{type: string, source: string}[]} */ let sources = [] - /** @type {{pos: {start: number, end: number}, value: string}[]} */ + /** @type {StringChange[]} */ let changes = [] /** @typedef {import('@shopify/prettier-plugin-liquid/dist/types.js').AttrSingleQuoted} AttrSingleQuoted */ @@ -303,19 +360,19 @@ function transformLiquid(ast, { env }) { for (let i = 0; i < attr.value.length; i++) { let node = attr.value[i] if (node.type === 'TextNode') { - node.value = sortClasses(node.value, { + let after = sortClasses(node.value, { env, ignoreFirst: i > 0 && !/^\s/.test(node.value), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(node.value), - collapseWhitespace: { - start: i === 0, - end: i >= attr.value.length - 1, - }, + removeDuplicates: false, + collapseWhitespace: false, }) changes.push({ - pos: node.position, - value: node.value, + start: node.position.start, + end: node.position.end, + before: node.value, + after, }) } else if ( // @ts-ignore: `LiquidDrop` is for older versions of the liquid plugin (1.2.x) @@ -334,11 +391,13 @@ function transformLiquid(ast, { env }) { pos.end -= 1 } - node.value = sortClasses(node.value, { env }) + let after = sortClasses(node.value, { env }) changes.push({ - pos, - value: node.value, + start: pos.start, + end: pos.end, + before: node.value, + after, }) }, }) @@ -370,23 +429,19 @@ function transformLiquid(ast, { env }) { }, }) - // Sort so all changes occur in order - changes = changes.sort((a, b) => { - return a.pos.start - b.pos.start || a.pos.end - b.pos.end - }) - - for (let change of changes) { - for (let node of sources) { - node.source = - node.source.slice(0, change.pos.start) + - change.value + - node.source.slice(change.pos.end) - } + for (let node of sources) { + node.source = spliceChangesIntoString(node.source, changes) } } -function sortStringLiteral(node, { env }) { - let result = sortClasses(node.value, { env }) +function sortStringLiteral( + node, + { env, collapseWhitespace = { start: true, end: true } }, +) { + let result = sortClasses(node.value, { + env, + collapseWhitespace, + }) let didChange = result !== node.value node.value = result if (node.extra) { @@ -412,7 +467,10 @@ function isStringLiteral(node) { ) } -function sortTemplateLiteral(node, { env }) { +function sortTemplateLiteral( + node, + { env, collapseWhitespace = { start: true, end: true } }, +) { let didChange = false for (let i = 0; i < node.quasis.length; i++) { @@ -431,8 +489,11 @@ function sortTemplateLiteral(node, { env }) { ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.raw), collapseWhitespace: { - start: i === 0, - end: i >= node.expressions.length, + start: collapseWhitespace && collapseWhitespace.start && i === 0, + end: + collapseWhitespace && + collapseWhitespace.end && + i >= node.expressions.length, }, }) @@ -444,8 +505,11 @@ function sortTemplateLiteral(node, { env }) { ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.cooked), collapseWhitespace: { - start: i === 0, - end: i >= node.expressions.length, + start: collapseWhitespace && collapseWhitespace.start && i === 0, + end: + collapseWhitespace && + collapseWhitespace.end && + i >= node.expressions.length, }, }) @@ -527,14 +591,35 @@ function transformJavaScript(ast, { env }) { /** @param {import('@babel/types').Node} ast */ function sortInside(ast) { - visit(ast, (node) => { + visit(ast, (node, parent, key) => { + let isConcat = + parent?.type === 'BinaryExpression' && parent?.operator === '+' + if (isStringLiteral(node)) { - sortStringLiteral(node, { env }) + sortStringLiteral(node, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) } else if (node.type === 'TemplateLiteral') { - sortTemplateLiteral(node, { env }) + sortTemplateLiteral(node, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) } else if (node.type === 'TaggedTemplateExpression') { if (isSortableTemplateExpression(node, functions)) { - sortTemplateLiteral(node.quasi, { env }) + sortTemplateLiteral(node.quasi, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) } } }) @@ -574,12 +659,21 @@ function transformJavaScript(ast, { env }) { }, /** @param {import('@babel/types').TaggedTemplateExpression} node */ - TaggedTemplateExpression(node) { + TaggedTemplateExpression(node, parent, key) { if (!isSortableTemplateExpression(node, functions)) { return } - sortTemplateLiteral(node.quasi, { env }) + let isConcat = + parent?.type === 'BinaryExpression' && parent?.operator === '+' + + sortTemplateLiteral(node.quasi, { + env, + collapseWhitespace: { + start: !(isConcat && key === 'right'), + end: !(isConcat && key === 'left'), + }, + }) }, }) } @@ -713,7 +807,9 @@ function transformMelody(ast, { env, changes }) { return } - const isConcat = parent.type === 'BinaryConcatExpression' + const isConcat = + parent.type === 'BinaryConcatExpression' || + parent.type === 'BinaryAddExpression' node.value = sortClasses(node.value, { env, @@ -810,10 +906,8 @@ function transformSvelte(ast, { env, changes }) { env, ignoreFirst: i > 0 && !/^\s/.test(value.raw), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.raw), - collapseWhitespace: { - start: i === 0, - end: i >= attr.value.length - 1, - }, + removeDuplicates: false, + collapseWhitespace: false, }) value.data = same ? value.raw @@ -821,24 +915,46 @@ function transformSvelte(ast, { env, changes }) { env, ignoreFirst: i > 0 && !/^\s/.test(value.data), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.data), - collapseWhitespace: { - start: i === 0, - end: i >= attr.value.length - 1, - }, + removeDuplicates: false, + collapseWhitespace: false, }) } else if (value.type === 'MustacheTag') { visit(value.expression, { - Literal(node) { + Literal(node, parent, key) { if (isStringLiteral(node)) { - if (sortStringLiteral(node, { env })) { - changes.push({ text: node.raw, loc: node.loc }) + let before = node.raw + let sorted = sortStringLiteral(node, { + env, + removeDuplicates: false, + collapseWhitespace: false, + }) + + if (sorted) { + changes.push({ + before, + after: node.raw, + start: node.loc.start, + end: node.loc.end, + }) } } }, - TemplateLiteral(node) { - if (sortTemplateLiteral(node, { env })) { - for (let quasi of node.quasis) { - changes.push({ text: quasi.value.raw, loc: quasi.loc }) + TemplateLiteral(node, parent, key) { + let before = node.quasis.map((quasi) => quasi.value.raw) + let sorted = sortTemplateLiteral(node, { + env, + removeDuplicates: false, + collapseWhitespace: false, + }) + + if (sorted) { + for (let [idx, quasi] of node.quasis.entries()) { + changes.push({ + before: before[idx], + after: quasi.value.raw, + start: quasi.loc.start, + end: quasi.loc.end, + }) } } }, @@ -884,24 +1000,22 @@ export const printers = (function () { options.__mutatedOriginalText = true let changes = path.stack[0].changes + if (changes?.length) { let finder = lineColumn(options.originalText) - for (let change of changes) { - let start = finder.toIndex( - change.loc.start.line, - change.loc.start.column + 1, - ) - let end = finder.toIndex( - change.loc.end.line, - change.loc.end.column + 1, - ) - - options.originalText = - options.originalText.substring(0, start) + - change.text + - options.originalText.substring(end) - } + changes = changes.map((change) => { + return { + ...change, + start: finder.toIndex(change.start.line, change.start.column + 1), + end: finder.toIndex(change.end.line, change.end.column + 1), + } + }) + + options.originalText = spliceChangesIntoString( + options.originalText, + changes, + ) } } diff --git a/src/options.js b/src/options.js index b6306827..debcbe70 100644 --- a/src/options.js +++ b/src/options.js @@ -37,6 +37,13 @@ export const options = { category: 'Tailwind CSS', description: 'Preserve whitespace around Tailwind classes when sorting', }, + tailwindPreserveDuplicates: { + since: '0.6.1', + type: 'boolean', + default: [{ value: false }], + category: 'Tailwind CSS', + description: 'Preserve duplicate classes inside a class list when sorting', + }, } /** @typedef {import('prettier').RequiredOptions} RequiredOptions */ diff --git a/src/sorting.js b/src/sorting.js index 6039691c..c66749dc 100644 --- a/src/sorting.js +++ b/src/sorting.js @@ -45,6 +45,7 @@ function getClassOrderPolyfill(classes, { env }) { * @param {any} opts.env * @param {boolean} [opts.ignoreFirst] * @param {boolean} [opts.ignoreLast] + * @param {boolean} [opts.removeDuplicates] * @param {object} [opts.collapseWhitespace] * @param {boolean} [opts.collapseWhitespace.start] * @param {boolean} [opts.collapseWhitespace.end] @@ -56,6 +57,7 @@ export function sortClasses( env, ignoreFirst = false, ignoreLast = false, + removeDuplicates = true, collapseWhitespace = { start: true, end: true }, }, ) { @@ -73,6 +75,10 @@ export function sortClasses( collapseWhitespace = false } + if (env.options.tailwindPreserveDuplicates) { + removeDuplicates = false + } + // This class list is purely whitespace // Collapse it to a single space if the option is enabled if (/^[\t\r\f\n ]+$/.test(classStr) && collapseWhitespace) { @@ -102,16 +108,17 @@ export function sortClasses( suffix = `${whitespace.pop() ?? ''}${classes.pop() ?? ''}` } - // Remove duplicates - classes = classes.filter((cls, index, arr) => { - if (arr.indexOf(cls) === index) { - return true - } + if (removeDuplicates) { + classes = classes.filter((cls, index, arr) => { + if (arr.indexOf(cls) === index) { + return true + } - whitespace.splice(index - 1, 1) + whitespace.splice(index - 1, 1) - return false - }) + return false + }) + } classes = sortClassList(classes, { env }) diff --git a/src/types.d.ts b/src/types.d.ts index 3a0b5710..83f4ad53 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -15,7 +15,7 @@ export interface Customizations { export interface TransformerContext { env: TransformerEnv - changes: { text: string; loc: any }[] + changes: StringChange[] } export interface TransformerEnv { @@ -39,3 +39,10 @@ declare module 'prettier' { interface RequiredOptions extends InternalOptions {} interface ParserOptions extends InternalOptions {} } + +export interface StringChange { + start: number + end: number + before: string + after: string +} diff --git a/src/utils.js b/src/utils.js index 30fab8e7..a11e1e26 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,5 @@ +/** @typedef {import('./types.js').StringChange} StringChange */ + // For loading prettier plugins only if they exist export function loadIfExists(name) { try { @@ -38,3 +40,25 @@ export function visit(ast, callbackMap) { } _visit(ast) } + +/** + * Apply the changes to the string such that a change in the length + * of the string does not break the indexes of the subsequent changes. + * @param {string} str + * @param {StringChange[]} changes + */ +export function spliceChangesIntoString(str, changes) { + // Sort all changes in reverse order so we apply them from the end of the string + // to the beginning. This way, the indexes for the changes after the current one + // will still be correct after applying the current one. + changes.sort((a, b) => { + return b.end - a.end || b.start - a.start + }) + + // Splice in each change to the string + for (let change of changes) { + str = str.slice(0, change.start) + change.after + str.slice(change.end) + } + + return str +} diff --git a/tests/format.test.js b/tests/format.test.js index 5191dcde..356c9ce6 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -10,6 +10,14 @@ let html = [ t`
`, // Ensure duplicate classes are removed ['
', '
'], + // Ensure duplicate can be kept + [ + '
', + '
', + { + tailwindPreserveDuplicates: true, + }, + ], ] let css = [ @@ -73,18 +81,12 @@ let javascript = [ ';
', ], [ - // This happens because we we look at class lists individually but - // a future improvement could be to dectect this case and not - // remove the space after flex. ';
', - ';
', + ';
', ], [ - // This happens because we we look at class lists individually but - // a future improvement could be to dectect this case and not - // remove the space after flex. - ';
', - ';
', + ';
', + ';
', ], ] javascript = javascript.concat( @@ -126,6 +128,14 @@ let vue = [ [`
`, `
`], [`
`, `
`], + [ + `
`, + `
`, + ], + [ + `
`, + `
`, + ], ] let glimmer = [ @@ -193,6 +203,11 @@ let tests = { t`
`, t`
`, + [ + `
`, + `
`, + ], + // TODO: Enable this test — it causes console noise but not a failure // t`
`, ], diff --git a/tests/plugins.test.js b/tests/plugins.test.js index 5c9913b7..b8542b2c 100644 --- a/tests/plugins.test.js +++ b/tests/plugins.test.js @@ -140,6 +140,15 @@ let tests = [ t`
`, t`
`, t`
`, + + [ + `
`, + `
`, + ], + [ + `
`, + `
`, + ], ], }, }, @@ -157,6 +166,11 @@ let tests = [ `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, ], + [ + `a.p-4.bg-blue-600(class=' sm:p-0 md:p-4 ', href='//example.com') Example`, + `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, + ], + // These two tests show how our sorting the two class lists separately is suboptimal // Two consecutive saves will result in different output // Where the second save is the most correct @@ -257,6 +271,18 @@ let tests = [ t`
`, t`
`, t`
`, + + // Whitespace removal is disabled for Liquid + // due to the way Liquid prints the AST + // (the length of the output MUST NOT change) + [ + `
`, + `
`, + ], + [ + `
`, + `
`, + ], ], }, }, @@ -289,6 +315,18 @@ let tests = [ '${yes}', ]/>`, t`
`, + + [ + `
`, + `
`, + ], + + // TODO: An improvement to the plugin would be to remove the whitespace + // in this scenario: + [ + `
`, + `
`, + ], ], }, }, @@ -332,6 +370,15 @@ import Custom from '../components/Custom.astro'
`, t``, t``, + + [ + `
`, + `
`, + ], + [ + `
`, + `
`, + ], ], }, }, @@ -369,6 +416,20 @@ import Custom from '../components/Custom.astro' ['
', '
'], t`{#await promise()}
{:then}
{/await}`, t`{#await promise() then}
{/await}`, + + // Whitespace removal is applied by Svelte itself + [ + `
`, + `
`, + ], + + // Whitespace removal does not work in Svelte + // due to how Svelte's parser and printer work + // (the length of the text MUST NOT change) + [ + `
`, + `
`, + ], ], }, },