diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c19157..70915262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Changed + +- Remove duplicate classes ([#272](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/272)) +- Remove extra whitespace around classes ([#272](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/272)) ## [0.5.14] - 2024-04-15 diff --git a/src/index.js b/src/index.js index 6639807e..0f1c31d3 100644 --- a/src/index.js +++ b/src/index.js @@ -234,6 +234,10 @@ function transformGlimmer(ast, { env }) { env, ignoreFirst: siblings?.prev && !/^\s/.test(node.chars), ignoreLast: siblings?.next && !/\s$/.test(node.chars), + collapseWhitespace: { + start: !siblings?.prev, + end: !siblings?.next, + }, }) }, @@ -248,6 +252,10 @@ function transformGlimmer(ast, { env }) { node.value = sortClasses(node.value, { env, ignoreLast: isConcat && !/[^\S\r\n]$/.test(node.value), + collapseWhitespace: { + start: false, + end: !isConcat, + }, }) }, }) @@ -299,6 +307,10 @@ function transformLiquid(ast, { env }) { 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, + }, }) changes.push({ @@ -411,8 +423,17 @@ function sortTemplateLiteral(node, { env }) { quasi.value.raw = sortClasses(quasi.value.raw, { env, + // Is not the first "item" and does not start with a space ignoreFirst: i > 0 && !/^\s/.test(quasi.value.raw), + + // Is between two expressions + // And does not end with a space ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.raw), + + collapseWhitespace: { + start: i === 0, + end: i >= node.expressions.length, + }, }) quasi.value.cooked = same @@ -422,6 +443,10 @@ function sortTemplateLiteral(node, { env }) { ignoreFirst: i > 0 && !/^\s/.test(quasi.value.cooked), ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.cooked), + collapseWhitespace: { + start: i === 0, + end: i >= node.expressions.length, + }, }) if ( @@ -566,11 +591,17 @@ function transformJavaScript(ast, { env }) { function transformCss(ast, { env }) { ast.walk((node) => { if (node.type === 'css-atrule' && node.name === 'apply') { + let isImportant = /\s+(?:!important|#{(['"]*)!important\1})\s*$/.test( + node.params, + ) + node.params = sortClasses(node.params, { env, - ignoreLast: /\s+(?:!important|#{(['"]*)!important\1})\s*$/.test( - node.params, - ), + ignoreLast: isImportant, + collapseWhitespace: { + start: false, + end: !isImportant, + }, }) } }) @@ -690,6 +721,10 @@ function transformMelody(ast, { env, changes }) { isConcat && _key === 'right' && !/^[^\S\r\n]/.test(node.value), ignoreLast: isConcat && _key === 'left' && !/[^\S\r\n]$/.test(node.value), + collapseWhitespace: { + start: !(isConcat && _key === 'right'), + end: !(isConcat && _key === 'left'), + }, }) }, }) @@ -775,6 +810,10 @@ 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, + }, }) value.data = same ? value.raw @@ -782,6 +821,10 @@ 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, + }, }) } else if (value.type === 'MustacheTag') { visit(value.expression, { diff --git a/src/options.js b/src/options.js index b08cfbe5..b6306827 100644 --- a/src/options.js +++ b/src/options.js @@ -30,6 +30,13 @@ export const options = { description: 'List of functions and tagged templates that contain sortable Tailwind classes', }, + tailwindPreserveWhitespace: { + since: '0.6.0', + type: 'boolean', + default: [{ value: false }], + category: 'Tailwind CSS', + description: 'Preserve whitespace around Tailwind classes when sorting', + }, } /** @typedef {import('prettier').RequiredOptions} RequiredOptions */ diff --git a/src/sorting.js b/src/sorting.js index 3785bfe9..6039691c 100644 --- a/src/sorting.js +++ b/src/sorting.js @@ -39,20 +39,46 @@ function getClassOrderPolyfill(classes, { env }) { return classNamesWithOrder } +/** + * @param {string} classStr + * @param {object} opts + * @param {any} opts.env + * @param {boolean} [opts.ignoreFirst] + * @param {boolean} [opts.ignoreLast] + * @param {object} [opts.collapseWhitespace] + * @param {boolean} [opts.collapseWhitespace.start] + * @param {boolean} [opts.collapseWhitespace.end] + * @returns {string} + */ export function sortClasses( classStr, - { env, ignoreFirst = false, ignoreLast = false }, + { + env, + ignoreFirst = false, + ignoreLast = false, + collapseWhitespace = { start: true, end: true }, + }, ) { if (typeof classStr !== 'string' || classStr === '') { return classStr } // Ignore class attributes containing `{{`, to match Prettier behaviour: - // https://github.com/prettier/prettier/blob/main/src/language-html/embed.js#L83-L88 + // https://github.com/prettier/prettier/blob/8a88cdce6d4605f206305ebb9204a0cabf96a070/src/language-html/embed/class-names.js#L9 if (classStr.includes('{{')) { return classStr } + if (env.options.tailwindPreserveWhitespace) { + collapseWhitespace = 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) { + return ' ' + } + let result = '' let parts = classStr.split(/([\t\r\f\n ]+)/) let classes = parts.filter((_, i) => i % 2 === 0) @@ -62,6 +88,10 @@ export function sortClasses( classes.pop() } + if (collapseWhitespace) { + whitespace = whitespace.map(() => ' ') + } + let prefix = '' if (ignoreFirst) { prefix = `${classes.shift() ?? ''}${whitespace.shift() ?? ''}` @@ -72,12 +102,32 @@ export function sortClasses( suffix = `${whitespace.pop() ?? ''}${classes.pop() ?? ''}` } + // Remove duplicates + classes = classes.filter((cls, index, arr) => { + if (arr.indexOf(cls) === index) { + return true + } + + whitespace.splice(index - 1, 1) + + return false + }) + classes = sortClassList(classes, { env }) for (let i = 0; i < classes.length; i++) { result += `${classes[i]}${whitespace[i] ?? ''}` } + if (collapseWhitespace) { + prefix = prefix.replace(/\s+$/g, ' ') + suffix = suffix.replace(/^\s+/g, ' ') + + result = result + .replace(/^\s+/, collapseWhitespace.start ? '' : ' ') + .replace(/\s+$/, collapseWhitespace.end ? '' : ' ') + } + return prefix + result + suffix } @@ -89,8 +139,6 @@ export function sortClassList(classList, { env }) { return classNamesWithOrder .sort(([, a], [, z]) => { if (a === z) return 0 - // if (a === null) return options.unknownClassPosition === 'start' ? -1 : 1 - // if (z === null) return options.unknownClassPosition === 'start' ? 1 : -1 if (a === null) return -1 if (z === null) return 1 return bigSign(a - z) diff --git a/tests/format.test.js b/tests/format.test.js index e0f2a462..5191dcde 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -8,13 +8,19 @@ let html = [ ['
', '
'], t`
`, t`
`, + // Ensure duplicate classes are removed + ['
', '
'], ] let css = [ t`@apply ${yes};`, t`/* @apply ${no}; */`, t`@not-apply ${no};`, - ['@apply sm:p-0\n p-0;', '@apply p-0\n sm:p-0;'], + [ + '@apply sm:p-0\n p-0;', + '@apply p-0\n sm:p-0;', + { tailwindPreserveWhitespace: true }, + ], ] let javascript = [ @@ -49,9 +55,44 @@ let javascript = [ `;
`, `;
`, ], + + // Whitespace is normalized and duplicates are removed + [ + ';
', + ';
', + ], + [ + ";
", + ";
", + ], + [';
', ';
'], + [';
', ';
'], + [';
', ';
'], + [ + ';
', + ';
', + ], + [ + // 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( - javascript.map((test) => test.map((t) => t.replace(/class/g, 'className'))), + javascript.map((test) => [ + test[0].replace(/class/g, 'className'), + test[1].replace(/class/g, 'className'), + test[2], + ]), ) let vue = [ @@ -82,6 +123,9 @@ let vue = [ `
`, `
`, ], + + [`
`, `
`], + [`
`, `
`], ] let glimmer = [ @@ -117,6 +161,13 @@ let glimmer = [ `
`, `
`, ], + + [`
`, `
`], + + [ + `
`, + `
`, + ], ] let tests = { @@ -167,15 +218,21 @@ let tests = { acorn: javascript, meriyah: javascript, mdx: javascript - .filter((test) => !test.find((t) => /^\/\*/.test(t))) - .map((test) => test.map((t) => t.replace(/^;/, ''))), + .filter((test) => { + return !/^\/\*/.test(test[0]) && !/^\/\*/.test(test[1]) + }) + .map((test) => [ + test[0].replace(/^;/, ''), + test[1].replace(/^;/, ''), + test[2], + ]), } describe('parsers', () => { for (let parser in tests) { test(parser, async () => { - for (let [input, expected] of tests[parser]) { - expect(await format(input, { parser })).toEqual(expected) + for (let [input, expected, options] of tests[parser]) { + expect(await format(input, { ...options, parser })).toEqual(expected) } }) } @@ -196,3 +253,49 @@ describe('other', () => { ).toEqual('
') }) }) + +describe('whitespace', () => { + test('class lists containing interpolation are ignored', async () => { + let result = await format('
') + expect(result).toEqual('
') + }) + + test('whitespace can be preserved around classes', async () => { + let result = await format( + `;
`, + { + parser: 'babel', + tailwindPreserveWhitespace: true, + }, + ) + expect(result).toEqual( + `;
`, + ) + }) + + test('whitespace can be collapsed around classes', async () => { + let result = await format( + '
', + ) + expect(result).toEqual('
') + }) + + test('whitespace is collapsed but not trimmed when ignored', async () => { + let result = await format( + ';
', + { + parser: 'babel', + }, + ) + expect(result).toEqual( + ';
', + ) + }) + + test('duplicate classes are dropped', async () => { + let result = await format( + '
', + ) + expect(result).toEqual('
') + }) +}) diff --git a/tests/plugins.test.js b/tests/plugins.test.js index 5c7236a7..5c9913b7 100644 --- a/tests/plugins.test.js +++ b/tests/plugins.test.js @@ -366,7 +366,7 @@ import Custom from '../components/Custom.astro' `
`, `
`, ], - ['
', ''], + ['
', '
'], t`{#await promise()}
{:then}
{/await}`, t`{#await promise() then}
{/await}`, ], diff --git a/tests/utils.js b/tests/utils.js index 4522d0c1..6d8fe8a2 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -23,7 +23,7 @@ module.exports.t = function t(strings, ...values) { output += string + value }) - return [input, output] + return [input, output, { tailwindPreserveWhitespace: true }] } let pluginPath = path.resolve(__dirname, '../dist/index.mjs')