Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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 @@ -34,6 +34,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-self-closing`](./html-no-self-closing.md.md) - Disallow self closing tags
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Linter Rule: No underscores on attribute names

**Rule:** `html-no-underscores-in-attribute-names`

## Description

---

Warn when an HTML attribute name contains an underscore (_). According to the HTML specification, attribute names should use only lowercase letters, digits, hyphens (-), and colons (:) in specific namespaces (e.g., xlink:href in SVG). Underscores are not valid in standard HTML attribute names and may lead to unpredictable behavior or be ignored by browsers entirely.

## Rationale

---

Underscores in attribute names violate the HTML specification and are not supported in standard markup. Their use is almost always accidental (e.g., mistyping data-attr_name instead of data-attr-name) or stems from inconsistent naming conventions across backend or templating layers.

## Examples

---

✅ Good

```html
<div data-user-id="123"></div>
<img aria-label="Close">
```

🚫 Bad

```html
<div data_user_id="123"></div>
<img aria_label="Close">
```

## References

---

\-
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/default-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { HTMLNoSelfClosingRule } from "./rules/html-no-self-closing.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"

export const defaultRules: RuleClass[] = [
ERBNoEmptyTagsRule,
Expand Down Expand Up @@ -68,4 +69,5 @@ export const defaultRules: RuleClass[] = [
HTMLTagNameLowercaseRule,
ParserNoErrorsRule,
SVGTagNameCapitalizationRule,
HTMLNoUnderscoresInAttributeNamesRule,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ParserRule } from "../types.js"
import {
AttributeVisitorMixin,
StaticAttributeStaticValueParams,
StaticAttributeDynamicValueParams,
DynamicAttributeStaticValueParams,
DynamicAttributeDynamicValueParams
} from "./rule-utils.js"
import { getStaticContentFromNodes } from "@herb-tools/core"

import type { LintContext, LintOffense } from "../types.js"
import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"

class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams): void {
this.check(attributeName, attributeNode)
}

protected checkStaticAttributeDynamicValue({ attributeName, attributeNode }: StaticAttributeDynamicValueParams): void {
this.check(attributeName, attributeNode)
}

protected checkDynamicAttributeStaticValue({ nameNodes, attributeNode }: DynamicAttributeStaticValueParams) {
const attributeName = getStaticContentFromNodes(nameNodes)

this.check(attributeName, attributeNode)
}

protected checkDynamicAttributeDynamicValue({ nameNodes, attributeNode }: DynamicAttributeDynamicValueParams) {
const attributeName = getStaticContentFromNodes(nameNodes)

this.check(attributeName, attributeNode)
}

private check(attributeName: string | null, attributeNode: HTMLAttributeNode): void {
if (!attributeName) return

if (attributeName.includes("_")) {
this.addOffense(
`Attribute \`${attributeName}\` should not contain underscores. Use hyphens (-) instead.`,
attributeNode.value!.location,
"warning"
)
}
}
}

export class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
name = "html-no-underscores-in-attribute-names"

check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context)

visitor.visit(result.value)

return visitor.offenses
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export * from "./html-no-self-closing.js"
export * from "./html-no-title-attribute.js"
export * from "./html-tag-name-lowercase.js"
export * from "./svg-tag-name-capitalization.js"
export * from "./html-no-underscores-in-attribute-names.js"
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import dedent from "dedent"

import { describe, test, expect, beforeAll } from "vitest"
import { Herb } from "@herb-tools/node-wasm"
import { Linter } from "../../src/linter.js"

import { HTMLNoUnderscoresInAttributeNamesRule } from "../../src/rules/html-no-underscores-in-attribute-names.js"

describe("html-no-underscores-in-attribute-names", () => {
beforeAll(async () => {
await Herb.load()
})

test("passes for valid attribute names (hyphens, letters, digits, colon)", () => {
const html = dedent`
<div data-user-id="123" aria-label="Close"></div>
<input data123-value="ok">
`

const linter = new Linter(Herb, [HTMLNoUnderscoresInAttributeNamesRule])
const lintResult = linter.lint(html)

expect(lintResult.warnings).toBe(0)
expect(lintResult.offenses).toHaveLength(0)
})

test("fails for attribute names with underscores", () => {
const html = dedent`
<div data_user_id="123"></div>
<img aria_label="Close">
<custom-element custom_attr="x"></custom-element>
`

const linter = new Linter(Herb, [HTMLNoUnderscoresInAttributeNamesRule])
const lintResult = linter.lint(html)

expect(lintResult.warnings).toBe(3)
expect(lintResult.offenses).toHaveLength(3)

expect(lintResult.offenses[0].rule).toBe("html-no-underscores-in-attribute-names")
expect(lintResult.offenses[0].severity).toBe("warning")

expect(lintResult.offenses[0].message).toBe("Attribute `data_user_id` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[1].message).toBe("Attribute `aria_label` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[2].message).toBe("Attribute `custom_attr` should not contain underscores. Use hyphens (-) instead.")
})

test("does not flag dynamic attribute names", () => {
const html = dedent`
<div data-<%= %>="value"></div>
<div <%= dynamic_name %>="value"></div>
<div data-<%= key %>-test="value"></div>
`

const linter = new Linter(Herb, [HTMLNoUnderscoresInAttributeNamesRule])
const lintResult = linter.lint(html)

expect(lintResult.warnings).toBe(0)
expect(lintResult.offenses).toHaveLength(0)
})

test("fails for dynamic attribute names with underscores", () => {
const html = dedent`
<div data_<%= key %>="value"></div>
<div data_<%= key %>-test="value"></div>
<div data-<%= key %>_test="value"></div>
`

const linter = new Linter(Herb, [HTMLNoUnderscoresInAttributeNamesRule])
const lintResult = linter.lint(html)

expect(lintResult.warnings).toBe(3)
expect(lintResult.offenses).toHaveLength(3)

expect(lintResult.offenses[0].message).toBe("Attribute `data_` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[1].message).toBe("Attribute `data_-test` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[2].message).toBe("Attribute `data-_test` should not contain underscores. Use hyphens (-) instead.")
})
})
1 change: 1 addition & 0 deletions javascript/packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"html-no-positive-tab-index",
"html-no-self-closing",
"html-no-title-attribute",
"html-no-underscores-in-attribute-names",
"html-tag-name-lowercase",
"parser-no-errors",
"svg-tag-name-capitalization"
Expand Down
Loading