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 @@ -28,6 +28,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-block-inside-inline`](./html-no-block-inside-inline.md) - Prevents block-level elements inside inline elements
- [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
- [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
- [`html-no-empty-attributes`](./html-no-empty-attributes.md) - Attributes must not have empty values
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
- [`html-no-positive-tab-index`](./html-no-positive-tab-index.md) - Avoid positive `tabindex` values
- [`html-no-self-closing`](./html-no-self-closing.md.md) - Disallow self closing tags
Expand Down
77 changes: 77 additions & 0 deletions javascript/packages/linter/docs/rules/html-no-empty-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Linter Rule: Attributes must not have empty values

**Rule:** `html-no-empty-attributes`

## Description

Warn when certain restricted attributes are present but have an empty string as their value. These attributes are required to have meaningful values to function properly, and leaving them empty is typically either a mistake or unnecessary.

In most cases, if the value is not available, it's better to omit the attribute entirely.

### Restricted attributes

- `id`
- `class`
- `name`
- `for`
- `src`
- `href`
- `title`
- `data`
- `role`
- `data-*`
- `aria-*`

## Rationale

Many HTML attributes are only useful when they carry a value. Leaving these attributes empty can:

- Produce confusing or misleading markup (e.g., `id=""`, `class=""`)
- Create inaccessible or invalid HTML
- Interfere with CSS or JS selectors expecting meaningful values
- Indicate unused or unfinished logic in ERB

This rule helps ensure that required attributes are only added when they are populated.

## Examples

### ✅ Good

```erb
<div id="header"></div>
<img src="/logo.png" alt="Company logo">
<input type="text" name="email">

<!-- Dynamic attributes with meaningful values -->
<div data-<%= key %>="<%= value %>" aria-<%= prop %>="<%= description %>">
Dynamic content
</div>

<!-- if no class should be set, omit it completely -->
<div>Plain div</div>
```

### 🚫 Bad

```erb
<div id=""></div>
<img src="">
<input name="">

<div data-config="">Content</div>
<button aria-label="">×</button>

<div class="">Plain div</div>

<!-- Dynamic attribute names with empty static values -->
<div data-<%= key %>="" aria-<%= prop %>=" ">
Problematic dynamic attributes
</div>
```

## References

- [HTML Living Standard - Global attributes](https://html.spec.whatwg.org/multipage/dom.html#global-attributes)
- [WCAG 2.2 - Text Alternatives](https://www.w3.org/WAI/WCAG22/Understanding/text-alternatives.html)
- [MDN - HTML attribute reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes)
- [ARIA in HTML](https://www.w3.org/TR/html-aria/)
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 @@ -24,6 +24,7 @@ import { HTMLNoAriaHiddenOnFocusableRule } from "./rules/html-no-aria-hidden-on-
// import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
import { HTMLNoDuplicateAttributesRule } from "./rules/html-no-duplicate-attributes.js"
import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
import { HTMLNoEmptyAttributesRule } from "./rules/html-no-empty-attributes.js"
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
import { HTMLNoPositiveTabIndexRule } from "./rules/html-no-positive-tab-index.js"
Expand Down Expand Up @@ -58,6 +59,7 @@ export const defaultRules: RuleClass[] = [
// HTMLNoBlockInsideInlineRule,
HTMLNoDuplicateAttributesRule,
HTMLNoDuplicateIdsRule,
HTMLNoEmptyAttributesRule,
HTMLNoEmptyHeadingsRule,
HTMLNoNestedLinksRule,
HTMLNoPositiveTabIndexRule,
Expand Down
75 changes: 75 additions & 0 deletions javascript/packages/linter/src/rules/html-no-empty-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ParserRule } from "../types.js"
import { AttributeVisitorMixin, StaticAttributeStaticValueParams, DynamicAttributeStaticValueParams } from "./rule-utils.js"

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

// Attributes that must not have empty values
const RESTRICTED_ATTRIBUTES = new Set([
'id',
'class',
'name',
'for',
'src',
'href',
'title',
'data',
'role'
])

// Check if attribute name matches any restricted patterns
function isRestrictedAttribute(attributeName: string): boolean {
// Check direct matches
if (RESTRICTED_ATTRIBUTES.has(attributeName)) {
return true
}

// Check for data-* attributes
if (attributeName.startsWith('data-')) {
return true
}

// Check for aria-* attributes
if (attributeName.startsWith('aria-')) {
return true
}

return false
}

class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
if (!isRestrictedAttribute(attributeName)) return
if (attributeValue.trim() !== "") return

this.addOffense(
`Attribute \`${attributeName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`,
attributeNode.name!.location,
"warning"
)
}

protected checkDynamicAttributeStaticValue({ combinedName, attributeValue, attributeNode }: DynamicAttributeStaticValueParams): void {
const name = (combinedName || "").toLowerCase()
if (!isRestrictedAttribute(name)) return
if (attributeValue.trim() !== "") return

this.addOffense(
`Attribute \`${combinedName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`,
attributeNode.name!.location,
"warning"
)
}
}

export class HTMLNoEmptyAttributesRule extends ParserRule {
name = "html-no-empty-attributes"

check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
const visitor = new NoEmptyAttributesVisitor(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 @@ -21,6 +21,7 @@ export * from "./html-no-aria-hidden-on-focusable.js"
export * from "./html-no-block-inside-inline.js"
export * from "./html-no-duplicate-attributes.js"
export * from "./html-no-duplicate-ids.js"
export * from "./html-no-empty-attributes.js"
export * from "./html-no-empty-headings.js"
export * from "./html-no-nested-links.js"
export * from "./html-no-positive-tab-index.js"
Expand Down
74 changes: 58 additions & 16 deletions javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

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

Loading
Loading