Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions javascript/packages/core/src/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,110 @@ export function forEachAttribute(node: HTMLElementNode | HTMLOpenTagNode, callba
}
}

// --- Class Name Grouping Utilities ---

/**
* Checks if a node is a whitespace-only literal (no visible content)
*/
export function isWhitespaceLiteral(node: Node): boolean {
return isLiteralNode(node) && !node.content.trim()
}

/**
* Splits literal nodes at whitespace boundaries into separate nodes.
* Non-literal nodes are passed through unchanged.
*
* For example, a literal `"bg-blue-500 text-white"` becomes two literals:
* `"bg-blue-500"` and `" "` and `"text-white"`.
*/
export function splitLiteralsAtWhitespace(nodes: Node[]): Node[] {
const result: Node[] = []

for (const node of nodes) {
if (isLiteralNode(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 {
result.push(node)
}
}

return result
}

/**
* Groups split nodes into "class groups" where each group represents a single
* class name (possibly spanning multiple nodes when ERB is interpolated).
*
* For example, `text-<%= color %>-500 bg-blue-500` produces two groups:
* - [`text-`, ERB, `-500`] (interpolated class)
* - [`bg-blue-500`] (static class)
*
* The key heuristic: a hyphen at a node boundary means the nodes are part of
* the same class name (e.g., `bg-` + ERB + `-500`), while whitespace means
* a new class name starts.
*/
export function groupNodesByClass(nodes: Node[]): Node[][] {
if (nodes.length === 0) return []

const groups: Node[][] = []
let currentGroup: Node[] = []

for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const previousNode = i > 0 ? nodes[i - 1] : null

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 (currentGroup.every(member => isWhitespaceLiteral(member))) {
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)
currentGroup = []
}

currentGroup.push(node)
}

if (currentGroup.length > 0) {
groups.push(currentGroup)
}

return groups
}

// --- Position Utilities ---

/**
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This page contains documentation for all Herb Linter rules.
- [`erb-no-inline-case-conditions`](./erb-no-inline-case-conditions.md) - Disallow inline `case`/`when` and `case`/`in` conditions in a single ERB tag
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
- [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
- [`erb-no-interpolated-class-names`](./erb-no-interpolated-class-names.md) - Disallow ERB interpolation inside CSS class names
- [`erb-no-extra-newline`](./erb-no-extra-newline.md) - Disallow extra newlines.
- [`erb-no-extra-whitespace-inside-tags`](./erb-no-extra-whitespace-inside-tags.md) - Disallow multiple consecutive spaces inside ERB tags
- [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Linter Rule: Disallow ERB interpolation inside CSS class names

**Rule:** `erb-no-interpolated-class-names`

## Description

Disallow ERB expressions that are embedded within CSS class names (e.g., `bg-<%= color %>-400`). Standalone ERB expressions that output complete class names are allowed.

## Rationale

Since tools like Tailwind CSS and [Herb's Tailwind class sorter](https://herb-tools.dev/projects/rewriter#tailwind-class-sorter) scan your source files as plain text, they have no way of understanding string interpolation. A class like `bg-<%= color %>-400` doesn't exist as a complete string anywhere in the source, so Tailwind won't generate it and the class sorter can't recognize or reorder it.

Beyond tooling, interpolated class names are also impossible to search for in editors. Searching for `bg-red-400` will never match `bg-<%= color %>-400`, making it difficult to find all usages of a class name across the codebase. Static analysis tools face the same problem since they can't determine which class names a template produces without evaluating the Ruby expression at runtime.

Instead, always use complete class names in your templates. This keeps each class name searchable, sortable, and statically analyzable.

## Examples

### ✅ Good

```erb
<div class="bg-blue-400 <%= dynamic_classes %> text-green-400"></div>
```

```erb
<div class="<%= classes %>"></div>
```

```erb
<div class="<%= a %> bg-blue-500 <%= b %>"></div>
```

```erb
<div class="<%= class_names('bg-blue-400': blue?, 'bg-red-400': red?) %>"></div>
```

```erb
<div class="<%= error? ? 'text-red-600' : 'text-green-600' %>"></div>
```

### 🚫 Bad

```erb
<div class="bg-<%= color %>-400"></div>
```

```erb
<div class="bg-<%= suffix %>"></div>
```

```erb
<div class="<%= prefix %>-blue-500"></div>
```

## References

- [Tailwind CSS: Dynamic class names](https://tailwindcss.com/docs/detecting-classes-in-source-files#dynamic-class-names)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ERBNoInlineCaseConditionsRule } from "./rules/erb-no-inline-case-condit
import { ERBNoConditionalHTMLElementRule } from "./rules/erb-no-conditional-html-element.js"
import { ERBNoConditionalOpenTagRule } from "./rules/erb-no-conditional-open-tag.js"
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
import { ERBNoInterpolatedClassNamesRule } from "./rules/erb-no-interpolated-class-names.js"
import { ERBNoExtraNewLineRule } from "./rules/erb-no-extra-newline.js"
import { ERBNoExtraWhitespaceRule } from "./rules/erb-no-extra-whitespace-inside-tags.js"
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
Expand Down Expand Up @@ -73,6 +74,7 @@ export const rules: RuleClass[] = [
ERBNoConditionalHTMLElementRule,
ERBNoConditionalOpenTagRule,
ERBNoEmptyTagsRule,
ERBNoInterpolatedClassNamesRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
ERBNoOutputControlFlowRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isLiteralNode, isWhitespaceLiteral, splitLiteralsAtWhitespace, groupNodesByClass } from "@herb-tools/core"
import { IdentityPrinter } from "@herb-tools/printer"

import { ParserRule } from "../types.js"
import { AttributeVisitorMixin } from "./rule-utils.js"

import type { Node } from "@herb-tools/core"
import type { StaticAttributeDynamicValueParams } from "./rule-utils.js"
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import type { ParseResult } from "@herb-tools/core"

function groupToString(group: Node[]): string {
return group.map(node => {
if (isLiteralNode(node)) {
return node.content
}

return IdentityPrinter.print(node, { ignoreErrors: true })
}).join("")
}

class ERBNoInterpolatedClassNamesVisitor extends AttributeVisitorMixin {
protected checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }: StaticAttributeDynamicValueParams) {
if (attributeName !== "class") return

const splitNodes = splitLiteralsAtWhitespace(valueNodes)
const groups = groupNodesByClass(splitNodes)

for (const group of groups) {
if (group.every(node => isWhitespaceLiteral(node))) continue

const isInterpolated = group.some(node => !isLiteralNode(node))
if (!isInterpolated) continue

const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim())
if (!hasAttachedLiteral) continue

const className = groupToString(group)

this.addOffense(
`Avoid ERB interpolation inside class names: \`${className}\`. Use standalone ERB expressions that output complete class names instead.`,
attributeNode.value!.location,
)
}
}
}

export class ERBNoInterpolatedClassNamesRule extends ParserRule {
static ruleName = "erb-no-interpolated-class-names"

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
severity: "warning"
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new ERBNoInterpolatedClassNamesVisitor(this.ruleName, context)

visitor.visit(result.value)

return visitor.offenses
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, test } from "vitest"

import { ERBNoInterpolatedClassNamesRule } from "../../src/rules/erb-no-interpolated-class-names.js"
import { createLinterTest } from "../helpers/linter-test-helper.js"

const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ERBNoInterpolatedClassNamesRule)

describe("ERBNoInterpolatedClassNamesRule", () => {
describe("valid cases", () => {
test("standalone ERB between static classes", () => {
expectNoOffenses(`<div class="bg-blue-400 <%= attributes %> text-green-400"></div>`)
})

test("only ERB content", () => {
expectNoOffenses(`<div class="<%= classes %>"></div>`)
})

test("multiple standalone ERBs", () => {
expectNoOffenses(`<div class="<%= a %> bg-blue-500 <%= b %>"></div>`)
})

test("fully static class attribute", () => {
expectNoOffenses(`<div class="bg-blue-400 text-white"></div>`)
})

test("non-class attribute with ERB interpolation", () => {
expectNoOffenses(`<div id="prefix-<%= id %>"></div>`)
})

test("ERB in data attribute, not class", () => {
expectNoOffenses(`<div data-value="item-<%= id %>-name" class="static-class"></div>`)
})
})

describe("invalid cases", () => {
test("ERB in middle of class name", () => {
expectWarning("Avoid ERB interpolation inside class names: `bg-<%= color %>-400`. Use standalone ERB expressions that output complete class names instead.")

assertOffenses(`<div class="bg-<%= color %>-400"></div>`)
})

test("ERB at end of class name, attached by hyphen", () => {
expectWarning("Avoid ERB interpolation inside class names: `bg-<%= suffix %>`. Use standalone ERB expressions that output complete class names instead.")

assertOffenses(`<div class="bg-<%= suffix %>"></div>`)
})

test("ERB at start of class name, attached by hyphen", () => {
expectWarning("Avoid ERB interpolation inside class names: `<%= prefix %>-blue-500`. Use standalone ERB expressions that output complete class names instead.")

assertOffenses(`<div class="<%= prefix %>-blue-500"></div>`)
})

test("two interpolated classes produce two offenses", () => {
expectWarning("Avoid ERB interpolation inside class names: `foo-<%= bar %>-100`. Use standalone ERB expressions that output complete class names instead.")
expectWarning("Avoid ERB interpolation inside class names: `baz-<%= qux %>-200`. Use standalone ERB expressions that output complete class names instead.")

assertOffenses(`<div class="foo-<%= bar %>-100 baz-<%= qux %>-200"></div>`)
})

test("only interpolated class flagged, standalone and static left alone", () => {
expectWarning("Avoid ERB interpolation inside class names: `bg-<%= color %>-400`. Use standalone ERB expressions that output complete class names instead.")

assertOffenses(`<div class="bg-<%= color %>-400 <%= standalone %> text-white"></div>`)
})
})
})
Loading
Loading