diff --git a/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts b/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts index 156710bf0..c907f96ea 100644 --- a/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts +++ b/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts @@ -45,7 +45,23 @@ class TailwindClassSorterVisitor extends Visitor { const attributeName = getStaticAttributeName(node.name) if (attributeName !== "class") return - this.visit(node.value) + const classAttributeSorter = new ClassAttributeSorter(this.sorter) + + classAttributeSorter.visit(node.value) + } +} + +/** + * Visitor that sorts classes within a single class attribute value. + * Only operates on the content of a class attribute, not the full document. + */ +class ClassAttributeSorter extends Visitor { + private sorter: TailwindClassSorter + + constructor(sorter: TailwindClassSorter) { + super() + + this.sorter = sorter } visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void { @@ -127,105 +143,244 @@ class TailwindClassSorterVisitor extends Visitor { }) } - private startsWithClassLiteral(nodes: Node[]): boolean { - return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim() - } - private isWhitespaceLiteral(node: Node): boolean { return isLiteralNode(node) && !node.content.trim() } - private formatNodes(nodes: Node[], isNested: boolean): Node[] { - const { classLiterals, others } = this.partitionNodes(nodes) - const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes) - - return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested) - } - - private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } { - const classLiterals: LiteralNode[] = [] - const others: Node[] = [] + private splitLiteralsAtWhitespace(nodes: Node[]): Node[] { + const result: Node[] = [] for (const node of nodes) { if (isLiteralNode(node)) { - if (node.content.trim()) { - classLiterals.push(node) - } else { - others.push(node) + const parts = node.content.match(/(\S+|\s+)/g) || [] + + for (const part of parts) { + result.push(new LiteralNode({ + type: "AST_LITERAL_NODE", + content: part, + errors: [], + location: node.location + })) } } else { - this.visit(node) - others.push(node) + result.push(node) } } - return { classLiterals, others } + return result } - private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] { - if (literals.length === 0 && others.length === 0) return [] - if (literals.length === 0) return others + private groupNodesByClass(nodes: Node[]): Node[][] { + if (nodes.length === 0) return [] - const fullContent = literals.map(n => n.content).join("") - const trimmedClasses = fullContent.trim() + const groups: Node[][] = [] + let currentGroup: Node[] = [] - if (!trimmedClasses) return others.length > 0 ? others : [] + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + const previousNode = i > 0 ? nodes[i - 1] : null - try { - const sortedClasses = this.sorter.sortClasses(trimmedClasses) + let startNewGroup = false + + if (currentGroup.length === 0) { + startNewGroup = false + } else if (isLiteralNode(node)) { + if (/^\s/.test(node.content)) { + startNewGroup = true + } else if (/^-/.test(node.content)) { + startNewGroup = false + } else if (previousNode && !isLiteralNode(previousNode)) { + startNewGroup = true + } + + } else { + if (previousNode && isLiteralNode(previousNode)) { + if (/\s$/.test(previousNode.content)) { + startNewGroup = true + } else if (/-$/.test(previousNode.content)) { + startNewGroup = false + } else { + startNewGroup = true + } + + } else if (previousNode && !isLiteralNode(previousNode)) { + startNewGroup = false + } + } + + if (startNewGroup && currentGroup.length > 0) { + groups.push(currentGroup) - if (others.length === 0) { - return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses) + currentGroup = [] } - return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested) - } catch (error) { - return [...literals, ...others] + currentGroup.push(node) } - } - private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] { - const leadingSpace = fullContent.match(/^\s*/)?.[0] || "" - const trailingSpace = fullContent.match(/\s*$/)?.[0] || "" - const alreadySorted = sortedClasses === trimmedClasses + if (currentGroup.length > 0) { + groups.push(currentGroup) + } - const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace) + return groups + } - asMutable(literal).content = sortedContent + private isInterpolatedGroup(group: Node[]): boolean { + return group.some(node => !isLiteralNode(node)) + } + + private isWhitespaceGroup(group: Node[]): boolean { + return group.every(node => this.isWhitespaceLiteral(node)) + } - return [literal] + private getStaticClassContent(group: Node[]): string { + return group + .filter(node => isLiteralNode(node)) + .map(node => (node as LiteralNode).content) + .join("") } - private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] { - const leadingSpace = fullContent.match(/^\s*/)?.[0] || "" - const trailingSpace = fullContent.match(/\s*$/)?.[0] || "" + private formatNodes(nodes: Node[], isNested: boolean): Node[] { + if (nodes.length === 0) return nodes + if (nodes.every(n => this.isWhitespaceLiteral(n))) return nodes + + const splitNodes = this.splitLiteralsAtWhitespace(nodes) + const groups = this.groupNodesByClass(splitNodes) + + const staticClasses: string[] = [] + const interpolationGroups: Node[][] = [] + const standaloneERBNodes: Node[] = [] + + for (const group of groups) { + if (this.isWhitespaceGroup(group)) { + continue + } + + if (this.isInterpolatedGroup(group)) { + const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim()) + + if (hasAttachedLiteral) { + for (const node of group) { + if (!isLiteralNode(node)) { + this.visit(node) + } + } + + interpolationGroups.push(group) + } else { + for (const node of group) { + if (!isLiteralNode(node)) { + this.visit(node) + standaloneERBNodes.push(node) + } + } + } + } else { + const content = this.getStaticClassContent(group).trim() + + if (content) { + staticClasses.push(content) + } + } + } + + const allStaticContent = staticClasses.join(" ") + let sortedContent = allStaticContent + + if (allStaticContent) { + try { + sortedContent = this.sorter.sortClasses(allStaticContent) + } catch { + // Keep original on error + } + } + + let addedLeadingSpace = false + + const result: Node[] = [] + const hasContent = sortedContent || interpolationGroups.length > 0 || standaloneERBNodes.length > 0 + const needsLeadingSpace = isNested && hasContent + + if (sortedContent) { + const literal = new LiteralNode({ + type: "AST_LITERAL_NODE", + content: (needsLeadingSpace ? " " : "") + sortedContent, + errors: [], + location: Location.zero + }) - const leading = preserveLeadingSpace ? leadingSpace : "" - const firstIsWhitespace = this.isWhitespaceLiteral(others[0]) - const spaceBetween = firstIsWhitespace ? "" : " " + result.push(literal) - asMutable(literal).content = leading + sortedClasses + spaceBetween + addedLeadingSpace = !!needsLeadingSpace + } + + for (const group of interpolationGroups) { + if (result.length > 0) { + result.push(this.spaceLiteral) + } else if (needsLeadingSpace && !addedLeadingSpace) { + result.push(this.spaceLiteral) + addedLeadingSpace = true + } + + const trimmedGroup = this.trimGroupWhitespace(group) + + result.push(...trimmedGroup) + } - const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace) + for (const node of standaloneERBNodes) { + if (result.length > 0) { + result.push(this.spaceLiteral) + } else if (needsLeadingSpace && !addedLeadingSpace) { + result.push(this.spaceLiteral) + addedLeadingSpace = true + } + result.push(node) + } + + if (isNested && result.length > 0) { + result.push(this.spaceLiteral) + } - return [literal, ...othersWithWhitespace] + return result } - private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] { - return nodes.flatMap((node, index) => { - const isLast = index >= nodes.length - 1 + private trimGroupWhitespace(group: Node[]): Node[] { + if (group.length === 0) return group - if (isLast) { - return isNested && trailingSpace ? [node, this.spaceLiteral] : [node] + const result = [...group] + + if (isLiteralNode(result[0])) { + const first = result[0] as LiteralNode + const trimmed = first.content.trimStart() + + if (trimmed !== first.content) { + result[0] = new LiteralNode({ + type: "AST_LITERAL_NODE", + content: trimmed, + errors: [], + location: first.location + }) } + } - const currentIsWhitespace = this.isWhitespaceLiteral(node) - const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1]) - const needsSpace = !currentIsWhitespace && !nextIsWhitespace + const lastIndex = result.length - 1 - return needsSpace ? [node, this.spaceLiteral] : [node] - }) + if (isLiteralNode(result[lastIndex])) { + const last = result[lastIndex] as LiteralNode + const trimmed = last.content.trimEnd() + + if (trimmed !== last.content) { + result[lastIndex] = new LiteralNode({ + type: "AST_LITERAL_NODE", + content: trimmed, + errors: [], + location: last.location + }) + } + } + + return result } + } /** diff --git a/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts b/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts index 9c0502c70..1b3cd5d11 100644 --- a/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts +++ b/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts @@ -115,6 +115,95 @@ describe("tailwind-class-sorter", () => { }) }) + describe("ERB string interpolation within class names", () => { + test("preserves interpolation in middle of class name (issue #879)", async () => { + await expectNoTransform( + `
` + ) + }) + + test("preserves interpolation at start of class name", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves interpolation at end of class name", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves interpolation with multiple ERB tags in middle", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves multiple ERB interpolations building one class name", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves complex interpolation with prefix, middle, and suffix", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves interpolation with static prefix and multiple dynamic parts", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves interpolation with dynamic prefix and static suffix", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves fully dynamic class with hyphens", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves multiple interpolated class names", async () => { + await expectNoTransform( + `` + ) + }) + + test("preserves multiple different interpolation patterns in same attribute", async () => { + await expectNoTransform( + `` + ) + }) + + test("sorts static classes and moves multiple interpolations to end", async () => { + await expectTransform( + ``, + `` + ) + }) + + test("sorts static classes and moves interpolation to end", async () => { + await expectTransform( + ``, + `` + ) + }) + + test("still sorts classes inside conditionals when interpolation is in main attribute", async () => { + await expectTransform( + ``, + `` + ) + }) + }) + describe("ERB nodes in class attributes", () => { test("moves ERB output node from middle to end", async () => { await expectTransform( @@ -245,14 +334,7 @@ describe("tailwind-class-sorter", () => { rounded"> `, - dedent` -