Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Linter Rule: Validate HTML elements and attributes

**Rule:** `html-validate-elements-attributes`

## Description

Validates HTML elements and their attributes against the HTML specification, including comprehensive attribute value validation.

This rule checks whether HTML elements are valid, whether attributes are allowed on specific elements, and whether attribute values match their expected types and formats. When ERB is present in attribute values, the rule skips value validation but still validates that the attribute name itself is valid for the element.

## Rationale

Validating HTML elements and attributes helps catch common errors that can lead to invalid HTML that may not render correctly across browsers, accessibility issues from malformed attributes, SEO problems from incorrect meta tags or link relationships, security vulnerabilities from improperly formatted URLs or IDs, and development confusion from typos in element or attribute names. By enforcing HTML specification compliance, this rule ensures your templates generate valid, accessible, and maintainable HTML.

## Examples

### ✅ Good

```erb
<!-- Valid HTML elements and attributes -->
<div id="container" class="main header-nav"></div>
<a href="/home" target="_blank" rel="noopener noreferrer">Link</a>
<img src="/logo.png" alt="Logo" width="100" height="50">

<!-- Valid attribute values -->
<input type="email" required autocomplete="email">
<form method="post" enctype="multipart/form-data">
<button type="submit" disabled>Submit</button>
<script type="module" src="/app.js" async defer></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<!-- Custom elements are allowed -->
<my-component custom-attr="value"></my-component>

<!-- data-* and aria-* attributes are allowed -->
<div data-user-id="123" data-role="admin"></div>
<button aria-label="Close" aria-expanded="false">X</button>

<!-- ERB values skip VALUE validation but attribute names are still validated -->
<div class="<%= dynamic_class %>">Content</div>
```

### 🚫 Bad

```erb
<invalidtag>Content</invalidtag>


<div href="/link">Not a link</div>

<span placeholder="Enter text"></span>


<input type="invalid-type">

<input required="false">

<a href="not a valid url">Link</a>

<input tabindex="not-a-number">

<input tabindex="-5">

<div class="123invalid">Content</div>

<label for="123invalid">Label</label>

<form method="invalid">

<button type="invalid">Button</button>

<div some-attr="<%= dynamic_value %>">Content</div>
```

## References

* [HTML Living Standard](https://html.spec.whatwg.org/)
* [MDN HTML Elements Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)
* [MDN HTML Attributes Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes)
* [W3C HTML Specification](https://www.w3.org/TR/html52/)
1 change: 1 addition & 0 deletions javascript/packages/linter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"build": "yarn clean && tsc -b && rollup -c",
"watch": "tsc -b -w",
"test": "vitest run",
"test:watch": "vitest --watch",
"prepublishOnly": "yarn clean && yarn build && yarn test"
},
"exports": {
Expand Down
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 @@ -20,6 +20,7 @@ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
import { HTMLValidateElementsAttributesRule } from "./rules/html-validate-elements-attributes.js"
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"

export const defaultRules: RuleClass[] = [
Expand All @@ -43,5 +44,6 @@ export const defaultRules: RuleClass[] = [
HTMLNoEmptyHeadingsRule,
HTMLNoNestedLinksRule,
HTMLTagNameLowercaseRule,
HTMLValidateElementsAttributesRule,
SVGTagNameCapitalizationRule,
]
2 changes: 1 addition & 1 deletion javascript/packages/linter/src/rules/erb-no-empty-tags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { BaseRuleVisitor } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { BaseRuleVisitor } from "./utils/rule-utils.js"

import type { Node, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
import { ParserRule } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Node, Token } from "@herb-tools/core"
import { isERBNode } from "@herb-tools/core";
import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"
import { BaseRuleVisitor } from "./utils/rule-utils.js"

class RequireWhitespaceInsideTags extends BaseRuleVisitor {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseSourceRuleVisitor, createEndOfFileLocation } from "./rule-utils.js"
import { BaseSourceRuleVisitor, createEndOfFileLocation } from "./utils/rule-utils.js"
import { SourceRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
import { BaseRuleVisitor, getTagName, hasAttribute } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ARIA_ATTRIBUTES,
AttributeVisitorMixin,
} from "./rule-utils.js";
} from "./utils/rule-utils.js";
import { ParserRule } from "../types.js";
import type { LintOffense, LintContext } from "../types.js";
import type {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin } from "./rule-utils.js"
import { AttributeVisitorMixin } from "./utils/rule-utils.js"
import { ParserRule } from "../types.js"

import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./rule-utils.js"
import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./rule-utils.js"
import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin } from "./rule-utils.js"
import { AttributeVisitorMixin } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
import { BaseRuleVisitor, getTagName, hasAttribute } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, isInlineElement, isBlockElement } from "./rule-utils.js"
import { BaseRuleVisitor, isInlineElement, isBlockElement } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, forEachAttribute } from "./rule-utils.js"
import { BaseRuleVisitor, forEachAttribute } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
6 changes: 3 additions & 3 deletions javascript/packages/linter/src/rules/html-no-duplicate-ids.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AttributeVisitorMixin } from "./rule-utils"
import { ParserRule } from "../types"
import { AttributeVisitorMixin } from "./utils/rule-utils.js"
import { ParserRule } from "../types.js"
import type { Node } from "@herb-tools/core"
import type { LintOffense, LintContext } from "../types"
import type { LintOffense, LintContext } from "../types.js"

class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
private documentIds: Set<string> = new Set<string>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./rule-utils.js"
import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, getTagName } from "./rule-utils.js"
import { BaseRuleVisitor, getTagName } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { BaseRuleVisitor } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { AttributeVisitorMixin, getTagName } from "./utils/rule-utils.js"
import { ParserRule } from "../types.js"

import { VALID_HTML_ELEMENTS, getValidAttributesForElement } from "./utils/html-element-attributes-map.js"
import { getAttributeValueRule } from "./utils/html-attribute-value-types.js"
import { validateAttributeValue } from "./utils/html-attribute-value-validators.js"

import type { LintOffense, LintContext } from "../types.js"
import type { HTMLAttributeNode, HTMLAttributeValueNode, HTMLOpenTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"

function attributeContainsERB(attributeNode: HTMLAttributeNode): boolean {
const valueNode = attributeNode.value as HTMLAttributeValueNode | null

if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
return false
}

for (const child of valueNode.children) {
if (child.type && child.type.startsWith("AST_ERB_")) {
return true
}
}

return false
}

class ValidateElementsAttributesVisitor extends AttributeVisitorMixin {
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
this.checkElement(node)
super.visitHTMLOpenTagNode(node)
}

visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
this.checkElement(node)
super.visitHTMLSelfCloseTagNode(node)
}

private checkElement(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
const tagName = getTagName(node)
if (!tagName) return

if (!VALID_HTML_ELEMENTS.has(tagName) && !tagName.includes("-")) {
this.addOffense(
`Unknown HTML element \`<${tagName}>\`. This element is not part of the HTML specification.`,
node.tag_name!.location,
"error"
)

return
}
}

protected checkAttribute(
attributeName: string,
attributeValue: string | null,
attributeNode: HTMLAttributeNode,
parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
): void {
const tagName = getTagName(parentNode)

if (!tagName) return
if (!VALID_HTML_ELEMENTS.has(tagName)) return
if (attributeName.startsWith("data-")) return
if (attributeName.startsWith("aria-")) return
if (attributeName.startsWith("on")) return

const isCustomElement = tagName.includes("-")
const validAttributes = getValidAttributesForElement(tagName)

if (!validAttributes.has(attributeName) && !isCustomElement) {
this.addOffense(
`Invalid attribute \`${attributeName}\` for \`<${tagName}>\` element. This attribute is not valid for this HTML element.`,
attributeNode.location,
"error"
)

return
}

if (attributeContainsERB(attributeNode)) return

const valueRule = getAttributeValueRule(tagName, attributeName)

if (valueRule) {
const validation = validateAttributeValue(attributeValue, valueRule, tagName, attributeName)

if (!validation.valid && validation.message) {
this.addOffense(
validation.message,
attributeNode.location,
"error"
)
}

if (validation.valid && validation.warning) {
this.addOffense(
validation.warning,
attributeNode.location,
"warning"
)
}
}
}
}

export class HTMLValidateElementsAttributesRule extends ParserRule {
name = "html-validate-elements-attributes"

check(node: Node, context?: Partial<LintContext>): LintOffense[] {
const visitor = new ValidateElementsAttributesVisitor(this.name, context)

visitor.visit(node)

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 @@ -16,4 +16,5 @@ export * from "./html-no-duplicate-ids.js"
export * from "./html-no-empty-headings.js"
export * from "./html-no-nested-links.js"
export * from "./html-tag-name-lowercase.js"
export * from "./html-validate-elements-attributes.js"
export * from "./svg-tag-name-capitalization.js"
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseRuleVisitor, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE } from "./rule-utils.js"
import { BaseRuleVisitor, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE } from "./utils/rule-utils.js"

import { ParserRule } from "../types.js"
import type { LintOffense, LintContext } from "../types.js"
Expand Down
Loading