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
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,45 @@
# 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">

<div data-<%= key %>-attribute="value"></div>
```

🚫 Bad

```html
<div data_user_id="123"></div>

<img aria_label="Close">

<div data-<%= key %>_attribute="value"></div>
```

## 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 { IdentityPrinter } from "@herb-tools/printer"
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 \`${IdentityPrinter.print(attributeNode.name)}\` 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"

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,78 @@
import dedent from "dedent"

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

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_<%= key %>` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[1].message).toBe("Attribute `data_<%= key %>-test` should not contain underscores. Use hyphens (-) instead.")
expect(lintResult.offenses[2].message).toBe("Attribute `data-<%= key %>_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