|
| 1 | +import { getStaticAttributeName, isLiteralNode } from "@herb-tools/core" |
| 2 | +import { LiteralNode, Location, Visitor } from "@herb-tools/core" |
| 3 | + |
| 4 | +import { TailwindClassSorter } from "@herb-tools/tailwind-class-sorter" |
| 5 | +import { ASTRewriter } from "../ast-rewriter.js" |
| 6 | +import { asMutable } from "../mutable.js" |
| 7 | + |
| 8 | +import type { RewriteContext } from "../context.js" |
| 9 | +import type { |
| 10 | + ParseResult, |
| 11 | + HTMLAttributeNode, |
| 12 | + HTMLAttributeValueNode, |
| 13 | + Node, |
| 14 | + ERBIfNode, |
| 15 | + ERBUnlessNode, |
| 16 | + ERBElseNode, |
| 17 | + ERBBlockNode, |
| 18 | + ERBForNode, |
| 19 | + ERBCaseNode, |
| 20 | + ERBWhenNode, |
| 21 | + ERBCaseMatchNode, |
| 22 | + ERBInNode, |
| 23 | + ERBWhileNode, |
| 24 | + ERBUntilNode, |
| 25 | + ERBBeginNode, |
| 26 | + ERBRescueNode, |
| 27 | + ERBEnsureNode |
| 28 | +} from "@herb-tools/core" |
| 29 | + |
| 30 | +/** |
| 31 | + * Visitor that traverses the AST and sorts Tailwind CSS classes in class attributes. |
| 32 | + */ |
| 33 | +class TailwindClassSorterVisitor extends Visitor { |
| 34 | + private sorter: TailwindClassSorter |
| 35 | + |
| 36 | + constructor(sorter: TailwindClassSorter) { |
| 37 | + super() |
| 38 | + |
| 39 | + this.sorter = sorter |
| 40 | + } |
| 41 | + |
| 42 | + visitHTMLAttributeNode(node: HTMLAttributeNode): void { |
| 43 | + if (!node.name) return |
| 44 | + if (!node.value) return |
| 45 | + |
| 46 | + const attributeName = getStaticAttributeName(node.name) |
| 47 | + if (attributeName !== "class") return |
| 48 | + |
| 49 | + this.visit(node.value) |
| 50 | + } |
| 51 | + |
| 52 | + visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void { |
| 53 | + asMutable(node).children = this.formatNodes(node.children, false) |
| 54 | + } |
| 55 | + |
| 56 | + visitERBIfNode(node: ERBIfNode): void { |
| 57 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 58 | + |
| 59 | + this.visit(node.subsequent) |
| 60 | + } |
| 61 | + |
| 62 | + visitERBElseNode(node: ERBElseNode): void { |
| 63 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 64 | + } |
| 65 | + |
| 66 | + visitERBUnlessNode(node: ERBUnlessNode): void { |
| 67 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 68 | + |
| 69 | + this.visit(node.else_clause) |
| 70 | + } |
| 71 | + |
| 72 | + visitERBBlockNode(node: ERBBlockNode): void { |
| 73 | + asMutable(node).body = this.formatNodes(node.body, true) |
| 74 | + } |
| 75 | + |
| 76 | + visitERBForNode(node: ERBForNode): void { |
| 77 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 78 | + } |
| 79 | + |
| 80 | + visitERBWhenNode(node: ERBWhenNode): void { |
| 81 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 82 | + } |
| 83 | + |
| 84 | + visitERBCaseNode(node: ERBCaseNode): void { |
| 85 | + this.visitAll(node.children) |
| 86 | + this.visit(node.else_clause) |
| 87 | + } |
| 88 | + |
| 89 | + visitERBCaseMatchNode(node: ERBCaseMatchNode): void { |
| 90 | + this.visitAll(node.children) |
| 91 | + this.visit(node.else_clause) |
| 92 | + } |
| 93 | + |
| 94 | + visitERBInNode(node: ERBInNode): void { |
| 95 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 96 | + } |
| 97 | + |
| 98 | + visitERBWhileNode(node: ERBWhileNode): void { |
| 99 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 100 | + } |
| 101 | + |
| 102 | + visitERBUntilNode(node: ERBUntilNode): void { |
| 103 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 104 | + } |
| 105 | + |
| 106 | + visitERBBeginNode(node: ERBBeginNode): void { |
| 107 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 108 | + this.visit(node.rescue_clause) |
| 109 | + this.visit(node.else_clause) |
| 110 | + this.visit(node.ensure_clause) |
| 111 | + } |
| 112 | + |
| 113 | + visitERBRescueNode(node: ERBRescueNode): void { |
| 114 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 115 | + this.visit(node.subsequent) |
| 116 | + } |
| 117 | + |
| 118 | + visitERBEnsureNode(node: ERBEnsureNode): void { |
| 119 | + asMutable(node).statements = this.formatNodes(node.statements, true) |
| 120 | + } |
| 121 | + |
| 122 | + private get spaceLiteral(): LiteralNode { |
| 123 | + return new LiteralNode({ |
| 124 | + type: "AST_LITERAL_NODE", |
| 125 | + content: " ", |
| 126 | + errors: [], |
| 127 | + location: Location.zero |
| 128 | + }) |
| 129 | + } |
| 130 | + |
| 131 | + private startsWithClassLiteral(nodes: Node[]): boolean { |
| 132 | + return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim() |
| 133 | + } |
| 134 | + |
| 135 | + private isWhitespaceLiteral(node: Node): boolean { |
| 136 | + return isLiteralNode(node) && !node.content.trim() |
| 137 | + } |
| 138 | + |
| 139 | + private formatNodes(nodes: Node[], isNested: boolean): Node[] { |
| 140 | + const { classLiterals, others } = this.partitionNodes(nodes) |
| 141 | + const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes) |
| 142 | + |
| 143 | + return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested) |
| 144 | + } |
| 145 | + |
| 146 | + private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } { |
| 147 | + const classLiterals: LiteralNode[] = [] |
| 148 | + const others: Node[] = [] |
| 149 | + |
| 150 | + for (const node of nodes) { |
| 151 | + if (isLiteralNode(node)) { |
| 152 | + if (node.content.trim()) { |
| 153 | + classLiterals.push(node) |
| 154 | + } else { |
| 155 | + others.push(node) |
| 156 | + } |
| 157 | + } else { |
| 158 | + this.visit(node) |
| 159 | + others.push(node) |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + return { classLiterals, others } |
| 164 | + } |
| 165 | + |
| 166 | + private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] { |
| 167 | + if (literals.length === 0 && others.length === 0) return [] |
| 168 | + if (literals.length === 0) return others |
| 169 | + |
| 170 | + const fullContent = literals.map(n => n.content).join("") |
| 171 | + const trimmedClasses = fullContent.trim() |
| 172 | + |
| 173 | + if (!trimmedClasses) return others.length > 0 ? others : [] |
| 174 | + |
| 175 | + try { |
| 176 | + const sortedClasses = this.sorter.sortClasses(trimmedClasses) |
| 177 | + |
| 178 | + if (others.length === 0) { |
| 179 | + return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses) |
| 180 | + } |
| 181 | + |
| 182 | + return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested) |
| 183 | + } catch (error) { |
| 184 | + return [...literals, ...others] |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] { |
| 189 | + const leadingSpace = fullContent.match(/^\s*/)?.[0] || "" |
| 190 | + const trailingSpace = fullContent.match(/\s*$/)?.[0] || "" |
| 191 | + const alreadySorted = sortedClasses === trimmedClasses |
| 192 | + |
| 193 | + const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace) |
| 194 | + |
| 195 | + asMutable(literal).content = sortedContent |
| 196 | + |
| 197 | + return [literal] |
| 198 | + } |
| 199 | + |
| 200 | + private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] { |
| 201 | + const leadingSpace = fullContent.match(/^\s*/)?.[0] || "" |
| 202 | + const trailingSpace = fullContent.match(/\s*$/)?.[0] || "" |
| 203 | + |
| 204 | + const leading = preserveLeadingSpace ? leadingSpace : "" |
| 205 | + const firstIsWhitespace = this.isWhitespaceLiteral(others[0]) |
| 206 | + const spaceBetween = firstIsWhitespace ? "" : " " |
| 207 | + |
| 208 | + asMutable(literal).content = leading + sortedClasses + spaceBetween |
| 209 | + |
| 210 | + const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace) |
| 211 | + |
| 212 | + return [literal, ...othersWithWhitespace] |
| 213 | + } |
| 214 | + |
| 215 | + private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] { |
| 216 | + return nodes.flatMap((node, index) => { |
| 217 | + const isLast = index >= nodes.length - 1 |
| 218 | + |
| 219 | + if (isLast) { |
| 220 | + return isNested && trailingSpace ? [node, this.spaceLiteral] : [node] |
| 221 | + } |
| 222 | + |
| 223 | + const currentIsWhitespace = this.isWhitespaceLiteral(node) |
| 224 | + const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1]) |
| 225 | + const needsSpace = !currentIsWhitespace && !nextIsWhitespace |
| 226 | + |
| 227 | + return needsSpace ? [node, this.spaceLiteral] : [node] |
| 228 | + }) |
| 229 | + } |
| 230 | +} |
| 231 | + |
| 232 | +/** |
| 233 | + * Built-in rewriter that sorts Tailwind CSS classes in class and className attributes |
| 234 | + */ |
| 235 | +export class TailwindClassSorterRewriter extends ASTRewriter { |
| 236 | + private sorter?: TailwindClassSorter |
| 237 | + |
| 238 | + get name(): string { |
| 239 | + return "tailwind-class-sorter" |
| 240 | + } |
| 241 | + |
| 242 | + get description(): string { |
| 243 | + return "Sorts Tailwind CSS classes in class and className attributes according to the recommended class order" |
| 244 | + } |
| 245 | + |
| 246 | + async initialize(context: RewriteContext): Promise<void> { |
| 247 | + this.sorter = await TailwindClassSorter.fromConfig({ |
| 248 | + baseDir: context.baseDir |
| 249 | + }) |
| 250 | + } |
| 251 | + |
| 252 | + rewrite(parseResult: ParseResult, _context: RewriteContext): ParseResult { |
| 253 | + if (!this.sorter) { |
| 254 | + return parseResult |
| 255 | + } |
| 256 | + |
| 257 | + const visitor = new TailwindClassSorterVisitor(this.sorter) |
| 258 | + |
| 259 | + visitor.visit(parseResult.value) |
| 260 | + |
| 261 | + return parseResult |
| 262 | + } |
| 263 | +} |
0 commit comments