From ee9ea05aa3b5203e3eb185fc235505b741bcc4fb Mon Sep 17 00:00:00 2001 From: Alexandros Eleftheriadis Date: Mon, 15 Sep 2025 13:11:46 +0300 Subject: [PATCH 1/5] Add options to match attributes by prefix and suffix --- src/index.ts | 64 ++++++++++++++++++++++++++++++++------------------ src/options.ts | 28 ++++++++++++++++++++++ src/types.ts | 4 ++++ 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index fc1cbc0..98046f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,8 @@ function createParser( ) { let customizationDefaults: Customizations = { staticAttrs: new Set(meta.staticAttrs ?? []), + prefixAttrs: new Set(meta.prefixAttrs ?? []), + suffixAttrs: new Set(meta.suffixAttrs ?? []), dynamicAttrs: new Set(meta.dynamicAttrs ?? []), functions: new Set(meta.functions ?? []), } @@ -273,11 +275,11 @@ function transformDynamicJsAttribute(attr: any, env: TransformerEnv) { } function transformHtml(ast: any, { env, changes }: TransformerContext) { - let { staticAttrs, dynamicAttrs } = env.customizations + let { dynamicAttrs } = env.customizations let { parser } = env.options for (let attr of ast.attrs ?? []) { - if (staticAttrs.has(attr.name)) { + if (isSortableAttribute(env.customizations, attr.name)) { attr.value = sortClasses(attr.value, { env }) } else if (dynamicAttrs.has(attr.name)) { if (!/[`'"]/.test(attr.value)) { @@ -298,11 +300,9 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { } function transformGlimmer(ast: any, { env }: TransformerContext) { - let { staticAttrs } = env.customizations - visit(ast, { AttrNode(attr, _path, meta) { - if (staticAttrs.has(attr.name) && attr.value) { + if (isSortableAttribute(env.customizations, attr.name) && attr.value) { meta.sortTextNodes = true } }, @@ -354,12 +354,10 @@ function transformGlimmer(ast: any, { env }: TransformerContext) { } function transformLiquid(ast: any, { env }: TransformerContext) { - let { staticAttrs } = env.customizations - function isClassAttr(node: { name: string | { type: string; value: string }[] }) { return Array.isArray(node.name) - ? node.name.every((n) => n.type === 'TextNode' && staticAttrs.has(n.value)) - : staticAttrs.has(node.name) + ? node.name.every((n) => n.type === 'TextNode' && isSortableAttribute(env.customizations, n.value)) + : isSortableAttribute(env.customizations, node.name) } function hasSurroundingQuotes(str: string) { @@ -566,6 +564,22 @@ function sortTemplateLiteral( return didChange } +function isSortableAttribute(customizations: Customizations, name: string) { + const { staticAttrs, suffixAttrs, prefixAttrs } = customizations + + if (staticAttrs.has(name)) return true + + for (const prefix of prefixAttrs) { + if (name.startsWith(prefix)) return true + } + + for (const suffix of suffixAttrs) { + if (name.endsWith(suffix)) return true + } + + return false +} + function isSortableTemplateExpression( node: import('@babel/types').TaggedTemplateExpression | import('ast-types').namedTypes.TaggedTemplateExpression, functions: Set, @@ -653,7 +667,7 @@ function canCollapseWhitespaceIn(path: Path) { // We cross several parsers that share roughly the same shape so things are // good enough. The actual AST we should be using is probably estree + ts. function transformJavaScript(ast: import('@babel/types').Node, { env }: TransformerContext) { - let { staticAttrs, functions } = env.customizations + let { functions } = env.customizations function sortInside(ast: import('@babel/types').Node) { visit(ast, (node, path) => { @@ -685,7 +699,7 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor return } - if (!staticAttrs.has(node.name.name)) { + if (!isSortableAttribute(env.customizations, node.name.name)) { return } @@ -787,11 +801,11 @@ function transformCss(ast: any, { env }: TransformerContext) { } function transformAstro(ast: any, { env, changes }: TransformerContext) { - let { staticAttrs, dynamicAttrs } = env.customizations + let { dynamicAttrs } = env.customizations if (ast.type === 'element' || ast.type === 'custom-element' || ast.type === 'component') { for (let attr of ast.attributes ?? []) { - if (staticAttrs.has(attr.name) && attr.type === 'attribute' && attr.kind === 'quoted') { + if (isSortableAttribute(env.customizations, attr.name) && attr.type === 'attribute' && attr.kind === 'quoted') { attr.value = sortClasses(attr.value, { env, }) @@ -812,8 +826,6 @@ function transformAstro(ast: any, { env, changes }: TransformerContext) { } function transformMarko(ast: any, { env }: TransformerContext) { - let { staticAttrs } = env.customizations - const nodesToVisit = [ast] while (nodesToVisit.length > 0) { const currentNode = nodesToVisit.pop() @@ -832,7 +844,7 @@ function transformMarko(ast: any, { env }: TransformerContext) { nodesToVisit.push(...currentNode.body) break case 'MarkoAttribute': - if (!staticAttrs.has(currentNode.name)) break + if (!isSortableAttribute(env.customizations, currentNode.name)) break switch (currentNode.value.type) { case 'ArrayExpression': const classList = currentNode.value.elements @@ -862,7 +874,7 @@ function transformTwig(ast: any, { env, changes }: TransformerContext) { visit(ast, { Attribute(node, _path, meta) { - if (!staticAttrs.has(node.name.name)) return + if (!isSortableAttribute(env.customizations, node.name.name)) return meta.sortTextNodes = true }, @@ -893,8 +905,6 @@ function transformTwig(ast: any, { env, changes }: TransformerContext) { } function transformPug(ast: any, { env }: TransformerContext) { - let { staticAttrs } = env.customizations - // This isn't optimal // We should merge the classes together across class attributes and class tokens // And then we sort them @@ -902,7 +912,7 @@ function transformPug(ast: any, { env }: TransformerContext) { // First sort the classes in attributes for (const token of ast.tokens) { - if (token.type === 'attribute' && staticAttrs.has(token.name)) { + if (token.type === 'attribute' && isSortableAttribute(env.customizations, token.name)) { token.val = [token.val.slice(0, 1), sortClasses(token.val.slice(1, -1), { env }), token.val.slice(-1)].join('') } } @@ -947,10 +957,8 @@ function transformPug(ast: any, { env }: TransformerContext) { } function transformSvelte(ast: any, { env, changes }: TransformerContext) { - let { staticAttrs } = env.customizations - for (let attr of ast.attributes ?? []) { - if (!staticAttrs.has(attr.name) || attr.type !== 'Attribute') { + if (!isSortableAttribute(env.customizations, attr.name) || attr.type !== 'Attribute') { continue } @@ -1238,6 +1246,16 @@ export interface PluginOptions { */ tailwindAttributes?: string[] + /** + * List of prefixes to match attributes that contain classes. + */ + tailwindAttributesStartsWith?: string[] + + /** + * List of suffixes to match attributes that contain classes. + */ + tailwindAttributesEndsWith?: string[] + /** * Preserve whitespace around Tailwind classes when sorting. */ diff --git a/src/options.ts b/src/options.ts index 98cd138..711ddcd 100644 --- a/src/options.ts +++ b/src/options.ts @@ -31,6 +31,22 @@ export const options: Record = { description: 'List of attributes/props that contain sortable Tailwind classes', }, + tailwindAttributesStartsWith: { + type: 'string', + array: true, + default: [{ value: [] }], + category: 'Tailwind CSS', + description: 'List of prefixes for attributes that contain sortable Tailwind classes', + }, + + tailwindAttributesEndsWith: { + type: 'string', + array: true, + default: [{ value: [] }], + category: 'Tailwind CSS', + description: 'List of suffixes for attributes that contain sortable Tailwind classes', + }, + tailwindFunctions: { type: 'string', array: true, @@ -63,6 +79,8 @@ export const options: Record = { export function getCustomizations(options: RequiredOptions, parser: string, defaults: Customizations): Customizations { let staticAttrs = new Set(defaults.staticAttrs) + let prefixAttrs = new Set(defaults.prefixAttrs) + let suffixAttrs = new Set(defaults.suffixAttrs) let dynamicAttrs = new Set(defaults.dynamicAttrs) let functions = new Set(defaults.functions) @@ -96,9 +114,19 @@ export function getCustomizations(options: RequiredOptions, parser: string, defa functions.add(fn) } + for (let attr of options.tailwindAttributesStartsWith ?? []) { + prefixAttrs.add(attr) + } + + for (let attr of options.tailwindAttributesEndsWith ?? []) { + suffixAttrs.add(attr) + } + return { functions, staticAttrs, dynamicAttrs, + prefixAttrs, + suffixAttrs, } } diff --git a/src/types.ts b/src/types.ts index 763aab0..87392fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,12 +4,16 @@ export interface TransformerMetadata { // Default customizations for a given transformer functions?: string[] staticAttrs?: string[] + prefixAttrs?: string[] + suffixAttrs?: string[] dynamicAttrs?: string[] } export interface Customizations { functions: Set staticAttrs: Set + prefixAttrs: Set + suffixAttrs: Set dynamicAttrs: Set } From f94f33bf265b388df9006e9ef699c7710d471fd9 Mon Sep 17 00:00:00 2001 From: Alexandros Eleftheriadis Date: Mon, 15 Sep 2025 13:12:09 +0300 Subject: [PATCH 2/5] Update documentation --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 0ecf87c..9020ac3 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,28 @@ function MyButton({ children }) { } ``` +#### Matching attributes with a prefix or suffix + +```json5 +// .prettierrc +{ + "tailwindAttributesStartsWith": ["data"], + "tailwindAttributesEndsWith": ["ClassName"] +} +``` + +With this configuration, attributes like `data-active-classes` and `buttonClassName` will be sorted: + +```jsx +function MyButton({ children }) { + return ( + + ); +} +``` + ## Sorting classes in function calls In addition to sorting classes in attributes, you can also sort classes in strings provided to function calls. This is useful when working with libraries like [clsx](https://github.com/lukeed/clsx) or [cva](https://cva.style/). From 64762d82d572174cb6a162b7a1e766046feef465 Mon Sep 17 00:00:00 2001 From: Alexandros Eleftheriadis Date: Mon, 15 Sep 2025 14:37:30 +0300 Subject: [PATCH 3/5] Improve attribute matching logic and documentation --- README.md | 13 +++++++++---- src/index.ts | 24 +++++++++++------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9020ac3..2245b1f 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,13 @@ If a local configuration file cannot be found the plugin will fallback to the de By default this plugin sorts classes in the `class` attribute, any framework-specific equivalents like `className`, `:class`, `[ngClass]`, and any Tailwind `@apply` directives. -You can sort additional attributes using the `tailwindAttributes` option, which takes an array of attribute names: +You can extend this behavior to sort classes in any attribute using the following options: + +- `tailwindAttributes`: An array of exact attribute names to sort. +- `tailwindAttributesStartsWith`: An array of prefixes to match attributes that begin with a certain string. +- `tailwindAttributesEndsWith`: An array of suffixes to match attributes that end with a certain string. + +#### Example 1 ```json5 // .prettierrc @@ -91,17 +97,16 @@ function MyButton({ children }) { } ``` -#### Matching attributes with a prefix or suffix +#### Example 2 ```json5 // .prettierrc { - "tailwindAttributesStartsWith": ["data"], "tailwindAttributesEndsWith": ["ClassName"] } ``` -With this configuration, attributes like `data-active-classes` and `buttonClassName` will be sorted: +With this configuration, any class found with suffix `ClassName` will be sorted: ```jsx function MyButton({ children }) { diff --git a/src/index.ts b/src/index.ts index 98046f1..7c69140 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,7 +279,7 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { let { parser } = env.options for (let attr of ast.attrs ?? []) { - if (isSortableAttribute(env.customizations, attr.name)) { + if (isSortableAttribute(attr.name, env.customizations)) { attr.value = sortClasses(attr.value, { env }) } else if (dynamicAttrs.has(attr.name)) { if (!/[`'"]/.test(attr.value)) { @@ -302,7 +302,7 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { function transformGlimmer(ast: any, { env }: TransformerContext) { visit(ast, { AttrNode(attr, _path, meta) { - if (isSortableAttribute(env.customizations, attr.name) && attr.value) { + if (isSortableAttribute(attr.name, env.customizations) && attr.value) { meta.sortTextNodes = true } }, @@ -356,8 +356,8 @@ function transformGlimmer(ast: any, { env }: TransformerContext) { function transformLiquid(ast: any, { env }: TransformerContext) { function isClassAttr(node: { name: string | { type: string; value: string }[] }) { return Array.isArray(node.name) - ? node.name.every((n) => n.type === 'TextNode' && isSortableAttribute(env.customizations, n.value)) - : isSortableAttribute(env.customizations, node.name) + ? node.name.every((n) => n.type === 'TextNode' && isSortableAttribute(n.value, env.customizations)) + : isSortableAttribute(node.name, env.customizations) } function hasSurroundingQuotes(str: string) { @@ -564,7 +564,7 @@ function sortTemplateLiteral( return didChange } -function isSortableAttribute(customizations: Customizations, name: string) { +function isSortableAttribute(name: string, customizations: Customizations) { const { staticAttrs, suffixAttrs, prefixAttrs } = customizations if (staticAttrs.has(name)) return true @@ -699,7 +699,7 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor return } - if (!isSortableAttribute(env.customizations, node.name.name)) { + if (!isSortableAttribute(node.name.name, env.customizations)) { return } @@ -805,7 +805,7 @@ function transformAstro(ast: any, { env, changes }: TransformerContext) { if (ast.type === 'element' || ast.type === 'custom-element' || ast.type === 'component') { for (let attr of ast.attributes ?? []) { - if (isSortableAttribute(env.customizations, attr.name) && attr.type === 'attribute' && attr.kind === 'quoted') { + if (isSortableAttribute(attr.name, env.customizations) && attr.type === 'attribute' && attr.kind === 'quoted') { attr.value = sortClasses(attr.value, { env, }) @@ -844,7 +844,7 @@ function transformMarko(ast: any, { env }: TransformerContext) { nodesToVisit.push(...currentNode.body) break case 'MarkoAttribute': - if (!isSortableAttribute(env.customizations, currentNode.name)) break + if (!isSortableAttribute(currentNode.name, env.customizations)) break switch (currentNode.value.type) { case 'ArrayExpression': const classList = currentNode.value.elements @@ -866,15 +866,13 @@ function transformMarko(ast: any, { env }: TransformerContext) { } function transformTwig(ast: any, { env, changes }: TransformerContext) { - let { staticAttrs } = env.customizations - for (let child of ast.expressions ?? []) { transformTwig(child, { env, changes }) } visit(ast, { Attribute(node, _path, meta) { - if (!isSortableAttribute(env.customizations, node.name.name)) return + if (!isSortableAttribute(node.name.name, env.customizations)) return meta.sortTextNodes = true }, @@ -912,7 +910,7 @@ function transformPug(ast: any, { env }: TransformerContext) { // First sort the classes in attributes for (const token of ast.tokens) { - if (token.type === 'attribute' && isSortableAttribute(env.customizations, token.name)) { + if (token.type === 'attribute' && isSortableAttribute(token.name, env.customizations)) { token.val = [token.val.slice(0, 1), sortClasses(token.val.slice(1, -1), { env }), token.val.slice(-1)].join('') } } @@ -958,7 +956,7 @@ function transformPug(ast: any, { env }: TransformerContext) { function transformSvelte(ast: any, { env, changes }: TransformerContext) { for (let attr of ast.attributes ?? []) { - if (!isSortableAttribute(env.customizations, attr.name) || attr.type !== 'Attribute') { + if (!isSortableAttribute(attr.name, env.customizations) || attr.type !== 'Attribute') { continue } From 52ba0ad7670320791ad9233b1a8ce5682f5e1214 Mon Sep 17 00:00:00 2001 From: Alexandros Eleftheriadis Date: Mon, 15 Sep 2025 15:00:06 +0300 Subject: [PATCH 4/5] Rename attribute matching options --- README.md | 6 +++--- src/index.ts | 4 ++-- src/options.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2245b1f..8d39c05 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ By default this plugin sorts classes in the `class` attribute, any framework-spe You can extend this behavior to sort classes in any attribute using the following options: - `tailwindAttributes`: An array of exact attribute names to sort. -- `tailwindAttributesStartsWith`: An array of prefixes to match attributes that begin with a certain string. -- `tailwindAttributesEndsWith`: An array of suffixes to match attributes that end with a certain string. +- `tailwindAttributesStartWith`: An array of prefixes to match attributes that begin with a certain string. +- `tailwindAttributesEndWith`: An array of suffixes to match attributes that end with a certain string. #### Example 1 @@ -102,7 +102,7 @@ function MyButton({ children }) { ```json5 // .prettierrc { - "tailwindAttributesEndsWith": ["ClassName"] + "tailwindAttributesEndWith": ["ClassName"] } ``` diff --git a/src/index.ts b/src/index.ts index 7c69140..d3c0bd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1247,12 +1247,12 @@ export interface PluginOptions { /** * List of prefixes to match attributes that contain classes. */ - tailwindAttributesStartsWith?: string[] + tailwindAttributesStartWith?: string[] /** * List of suffixes to match attributes that contain classes. */ - tailwindAttributesEndsWith?: string[] + tailwindAttributesEndWith?: string[] /** * Preserve whitespace around Tailwind classes when sorting. diff --git a/src/options.ts b/src/options.ts index 711ddcd..4c38319 100644 --- a/src/options.ts +++ b/src/options.ts @@ -31,7 +31,7 @@ export const options: Record = { description: 'List of attributes/props that contain sortable Tailwind classes', }, - tailwindAttributesStartsWith: { + tailwindAttributesStartWith: { type: 'string', array: true, default: [{ value: [] }], @@ -39,7 +39,7 @@ export const options: Record = { description: 'List of prefixes for attributes that contain sortable Tailwind classes', }, - tailwindAttributesEndsWith: { + tailwindAttributesEndWith: { type: 'string', array: true, default: [{ value: [] }], @@ -114,11 +114,11 @@ export function getCustomizations(options: RequiredOptions, parser: string, defa functions.add(fn) } - for (let attr of options.tailwindAttributesStartsWith ?? []) { + for (let attr of options.tailwindAttributesStartWith ?? []) { prefixAttrs.add(attr) } - for (let attr of options.tailwindAttributesEndsWith ?? []) { + for (let attr of options.tailwindAttributesEndWith ?? []) { suffixAttrs.add(attr) } From 20b63a4c6efade6579dcd2775f269b6e5727ca41 Mon Sep 17 00:00:00 2001 From: Alexandros Eleftheriadis Date: Mon, 15 Sep 2025 19:52:00 +0300 Subject: [PATCH 5/5] Prioritize dynamic attribute check over static --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index d3c0bd7..d241e06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,9 +279,7 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { let { parser } = env.options for (let attr of ast.attrs ?? []) { - if (isSortableAttribute(attr.name, env.customizations)) { - attr.value = sortClasses(attr.value, { env }) - } else if (dynamicAttrs.has(attr.name)) { + if (dynamicAttrs.has(attr.name)) { if (!/[`'"]/.test(attr.value)) { continue } @@ -291,6 +289,8 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { } else { transformDynamicJsAttribute(attr, env) } + } else if (isSortableAttribute(attr.name, env.customizations)) { + attr.value = sortClasses(attr.value, { env }) } }