Skip to content

Commit d406431

Browse files
committed
Add html-validate-elements-attributes linter rule
1 parent 02a1389 commit d406431

File tree

11 files changed

+2551
-7
lines changed

11 files changed

+2551
-7
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Linter Rule: Validate HTML elements and attributes
2+
3+
**Rule:** `html-validate-elements-attributes`
4+
5+
## Description
6+
7+
Validates HTML elements and their attributes against the HTML specification, including comprehensive attribute value validation.
8+
9+
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.
10+
11+
## Rationale
12+
13+
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.
14+
15+
## Examples
16+
17+
### ✅ Good
18+
19+
```erb
20+
<!-- Valid HTML elements and attributes -->
21+
<div id="container" class="main header-nav"></div>
22+
<a href="/home" target="_blank" rel="noopener noreferrer">Link</a>
23+
<img src="/logo.png" alt="Logo" width="100" height="50">
24+
25+
<!-- Valid attribute values -->
26+
<input type="email" required autocomplete="email">
27+
<form method="post" enctype="multipart/form-data">
28+
<button type="submit" disabled>Submit</button>
29+
<script type="module" src="/app.js" async defer></script>
30+
<meta charset="UTF-8">
31+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
32+
33+
<!-- Custom elements are allowed -->
34+
<my-component custom-attr="value"></my-component>
35+
36+
<!-- data-* and aria-* attributes are allowed -->
37+
<div data-user-id="123" data-role="admin"></div>
38+
<button aria-label="Close" aria-expanded="false">X</button>
39+
40+
<!-- ERB values skip VALUE validation but attribute names are still validated -->
41+
<div class="<%= dynamic_class %>">Content</div>
42+
```
43+
44+
### 🚫 Bad
45+
46+
```erb
47+
<invalidtag>Content</invalidtag>
48+
49+
50+
<div href="/link">Not a link</div>
51+
52+
<span placeholder="Enter text"></span>
53+
54+
55+
<input type="invalid-type">
56+
57+
<input required="false">
58+
59+
<a href="not a valid url">Link</a>
60+
61+
<input tabindex="not-a-number">
62+
63+
<input tabindex="-5">
64+
65+
<div class="123invalid">Content</div>
66+
67+
<label for="123invalid">Label</label>
68+
69+
<form method="invalid">
70+
71+
<button type="invalid">Button</button>
72+
73+
<div some-attr="<%= dynamic_value %>">Content</div>
74+
```
75+
76+
## References
77+
78+
* [HTML Living Standard](https://html.spec.whatwg.org/)
79+
* [MDN HTML Elements Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)
80+
* [MDN HTML Attributes Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes)
81+
* [W3C HTML Specification](https://www.w3.org/TR/html52/)

javascript/packages/linter/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"build": "yarn clean && tsc -b && rollup -c",
2222
"watch": "tsc -b -w",
2323
"test": "vitest run",
24+
"test:watch": "vitest --watch",
2425
"prepublishOnly": "yarn clean && yarn build && yarn test"
2526
},
2627
"exports": {

javascript/packages/linter/src/default-rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
2020
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
2121
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
2222
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
23+
// import { HTMLValidateElementsAttributesRule } from "./rules/html-validate-elements-attributes.js"
2324
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
2425

2526
export const defaultRules: RuleClass[] = [
@@ -43,5 +44,6 @@ export const defaultRules: RuleClass[] = [
4344
HTMLNoEmptyHeadingsRule,
4445
HTMLNoNestedLinksRule,
4546
HTMLTagNameLowercaseRule,
47+
// HTMLValidateElementsAttributesRule,
4648
SVGTagNameCapitalizationRule,
4749
]
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
export type AttributeValueType =
2+
| "boolean" // Boolean attributes (present = true, absent = false)
3+
| "string" // Any string value
4+
| "number" // Numeric value
5+
| "url" // Valid URL
6+
| "email" // Valid email address
7+
| "color" // Valid color value
8+
| "datetime" // Valid datetime string
9+
| "language" // Valid language code
10+
| "mime-type" // Valid MIME type
11+
| "enum" // One of a specific set of values
12+
| "space-separated" // Space-separated list of values
13+
| "comma-separated" // Comma-separated list of values
14+
| "pattern" // Must match a regex pattern
15+
| "id-reference" // Reference to an element ID
16+
| "class-list" // Space-separated class names
17+
18+
export interface AttributeValueRule {
19+
type: AttributeValueType
20+
enum?: string[]
21+
pattern?: RegExp
22+
min?: number
23+
max?: number
24+
allowEmpty?: boolean
25+
caseSensitive?: boolean
26+
}
27+
28+
export const GLOBAL_ATTRIBUTE_TYPES: Record<string, AttributeValueRule> = {
29+
"id": { type: "string", pattern: /^[a-zA-Z][\w-]*$/ },
30+
"class": { type: "class-list" },
31+
"style": { type: "string" },
32+
"title": { type: "string" },
33+
"lang": { type: "language" },
34+
"dir": { type: "enum", enum: ["ltr", "rtl", "auto"], caseSensitive: false },
35+
"hidden": { type: "boolean" },
36+
"tabindex": { type: "number", min: -1 },
37+
"accesskey": { type: "string" },
38+
"contenteditable": { type: "enum", enum: ["true", "false", "", "plaintext-only"], caseSensitive: false },
39+
"draggable": { type: "enum", enum: ["true", "false", "auto"], caseSensitive: false },
40+
"spellcheck": { type: "enum", enum: ["true", "false", "default"], caseSensitive: false },
41+
"translate": { type: "enum", enum: ["yes", "no"], caseSensitive: false },
42+
"role": { type: "string" }, // ARIA role - validated by separate rule
43+
}
44+
45+
export const ELEMENT_ATTRIBUTE_TYPES: Record<string, Record<string, AttributeValueRule>> = {
46+
a: {
47+
"href": { type: "url", allowEmpty: true },
48+
"target": { type: "enum", enum: ["_blank", "_self", "_parent", "_top"], caseSensitive: false },
49+
"rel": { type: "space-separated", enum: ["alternate", "author", "bookmark", "canonical", "dns-prefetch", "external", "help", "icon", "license", "manifest", "modulepreload", "next", "nofollow", "noopener", "noreferrer", "opener", "pingback", "preconnect", "prefetch", "preload", "prev", "search", "stylesheet", "tag"] },
50+
"download": { type: "string", allowEmpty: true },
51+
"hreflang": { type: "language" },
52+
"type": { type: "mime-type" },
53+
"referrerpolicy": { type: "enum", enum: ["no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], caseSensitive: false },
54+
},
55+
56+
img: {
57+
"src": { type: "url", allowEmpty: false },
58+
"alt": { type: "string", allowEmpty: true },
59+
"width": { type: "number", min: 0 },
60+
"height": { type: "number", min: 0 },
61+
"loading": { type: "enum", enum: ["eager", "lazy"], caseSensitive: false },
62+
"decoding": { type: "enum", enum: ["sync", "async", "auto"], caseSensitive: false },
63+
"crossorigin": { type: "enum", enum: ["anonymous", "use-credentials", ""], caseSensitive: false },
64+
"referrerpolicy": { type: "enum", enum: ["no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], caseSensitive: false },
65+
"srcset": { type: "string" }, // Complex format
66+
"sizes": { type: "string" }, // Complex format
67+
},
68+
69+
input: {
70+
"type": { type: "enum", enum: ["button", "checkbox", "color", "date", "datetime-local", "email", "file", "hidden", "image", "month", "number", "password", "radio", "range", "reset", "search", "submit", "tel", "text", "time", "url", "week"], caseSensitive: false },
71+
"name": { type: "string", allowEmpty: false },
72+
"value": { type: "string", allowEmpty: true },
73+
"placeholder": { type: "string" },
74+
"required": { type: "boolean" },
75+
"disabled": { type: "boolean" },
76+
"readonly": { type: "boolean" },
77+
"checked": { type: "boolean" },
78+
"multiple": { type: "boolean" },
79+
"autofocus": { type: "boolean" },
80+
"autocomplete": { type: "enum", enum: ["on", "off", "name", "email", "username", "current-password", "new-password", "one-time-code", "street-address", "address-level1", "address-level2", "postal-code", "country", "tel", "url"], caseSensitive: false },
81+
"min": { type: "string" }, // Can be number or date depending on input type
82+
"max": { type: "string" }, // Can be number or date depending on input type
83+
"step": { type: "number", min: 0 },
84+
"maxlength": { type: "number", min: 0 },
85+
"minlength": { type: "number", min: 0 },
86+
"pattern": { type: "string" }, // Regex pattern
87+
"accept": { type: "comma-separated" }, // MIME types
88+
"size": { type: "number", min: 1 },
89+
},
90+
91+
button: {
92+
"type": { type: "enum", enum: ["button", "submit", "reset"], caseSensitive: false },
93+
"name": { type: "string", allowEmpty: false },
94+
"value": { type: "string", allowEmpty: true },
95+
"disabled": { type: "boolean" },
96+
"autofocus": { type: "boolean" },
97+
},
98+
99+
form: {
100+
"action": { type: "url", allowEmpty: true },
101+
"method": { type: "enum", enum: ["get", "post", "dialog"], caseSensitive: false },
102+
"enctype": { type: "enum", enum: ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"], caseSensitive: false },
103+
"target": { type: "enum", enum: ["_blank", "_self", "_parent", "_top"], caseSensitive: false },
104+
"novalidate": { type: "boolean" },
105+
"autocomplete": { type: "enum", enum: ["on", "off"], caseSensitive: false },
106+
},
107+
108+
label: {
109+
"for": { type: "id-reference" },
110+
},
111+
112+
select: {
113+
"name": { type: "string", allowEmpty: false },
114+
"multiple": { type: "boolean" },
115+
"required": { type: "boolean" },
116+
"disabled": { type: "boolean" },
117+
"autofocus": { type: "boolean" },
118+
"size": { type: "number", min: 1 },
119+
},
120+
121+
option: {
122+
"value": { type: "string", allowEmpty: true },
123+
"selected": { type: "boolean" },
124+
"disabled": { type: "boolean" },
125+
"label": { type: "string" },
126+
},
127+
128+
textarea: {
129+
"name": { type: "string", allowEmpty: false },
130+
"placeholder": { type: "string" },
131+
"required": { type: "boolean" },
132+
"disabled": { type: "boolean" },
133+
"readonly": { type: "boolean" },
134+
"autofocus": { type: "boolean" },
135+
"rows": { type: "number", min: 1 },
136+
"cols": { type: "number", min: 1 },
137+
"maxlength": { type: "number", min: 0 },
138+
"minlength": { type: "number", min: 0 },
139+
"wrap": { type: "enum", enum: ["hard", "soft"], caseSensitive: false },
140+
},
141+
142+
video: {
143+
"src": { type: "url", allowEmpty: false },
144+
"poster": { type: "url", allowEmpty: false },
145+
"width": { type: "number", min: 0 },
146+
"height": { type: "number", min: 0 },
147+
"controls": { type: "boolean" },
148+
"autoplay": { type: "boolean" },
149+
"loop": { type: "boolean" },
150+
"muted": { type: "boolean" },
151+
"preload": { type: "enum", enum: ["none", "metadata", "auto"], caseSensitive: false },
152+
"crossorigin": { type: "enum", enum: ["anonymous", "use-credentials", ""], caseSensitive: false },
153+
},
154+
155+
audio: {
156+
"src": { type: "url", allowEmpty: false },
157+
"controls": { type: "boolean" },
158+
"autoplay": { type: "boolean" },
159+
"loop": { type: "boolean" },
160+
"muted": { type: "boolean" },
161+
"preload": { type: "enum", enum: ["none", "metadata", "auto"], caseSensitive: false },
162+
"crossorigin": { type: "enum", enum: ["anonymous", "use-credentials", ""], caseSensitive: false },
163+
},
164+
165+
link: {
166+
"href": { type: "url", allowEmpty: false },
167+
"rel": { type: "space-separated", enum: ["alternate", "apple-touch-icon", "canonical", "dns-prefetch", "icon", "manifest", "modulepreload", "next", "pingback", "preconnect", "prefetch", "preload", "prev", "search", "stylesheet"] },
168+
"type": { type: "mime-type" },
169+
"media": { type: "string" }, // Media query
170+
"sizes": { type: "string" },
171+
"crossorigin": { type: "enum", enum: ["anonymous", "use-credentials", ""], caseSensitive: false },
172+
"integrity": { type: "string" }, // Subresource integrity hash
173+
"referrerpolicy": { type: "enum", enum: ["no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], caseSensitive: false },
174+
},
175+
176+
meta: {
177+
"name": { type: "string", allowEmpty: false },
178+
"content": { type: "string", allowEmpty: true },
179+
"charset": { type: "string" }, // Character encoding
180+
"http-equiv": { type: "enum", enum: ["content-security-policy", "content-type", "default-style", "refresh", "x-ua-compatible"], caseSensitive: false },
181+
"property": { type: "string" }, // Open Graph, Twitter Cards, etc.
182+
},
183+
184+
script: {
185+
"src": { type: "url", allowEmpty: false },
186+
"type": { type: "enum", enum: ["text/javascript", "application/javascript", "module", "text/ecmascript", "application/ecmascript"], caseSensitive: false },
187+
"async": { type: "boolean" },
188+
"defer": { type: "boolean" },
189+
"crossorigin": { type: "enum", enum: ["anonymous", "use-credentials", ""], caseSensitive: false },
190+
"integrity": { type: "string" }, // Subresource integrity hash
191+
"nomodule": { type: "boolean" },
192+
"referrerpolicy": { type: "enum", enum: ["no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], caseSensitive: false },
193+
},
194+
195+
iframe: {
196+
"src": { type: "url", allowEmpty: false },
197+
"srcdoc": { type: "string" },
198+
"name": { type: "string" },
199+
"width": { type: "number", min: 0 },
200+
"height": { type: "number", min: 0 },
201+
"allowfullscreen": { type: "boolean" },
202+
"referrerpolicy": { type: "enum", enum: ["no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], caseSensitive: false },
203+
"sandbox": { type: "space-separated", enum: ["allow-forms", "allow-modals", "allow-orientation-lock", "allow-pointer-lock", "allow-popups", "allow-popups-to-escape-sandbox", "allow-presentation", "allow-same-origin", "allow-scripts", "allow-top-navigation", "allow-top-navigation-by-user-activation"] },
204+
"loading": { type: "enum", enum: ["eager", "lazy"], caseSensitive: false },
205+
},
206+
207+
table: {
208+
"border": { type: "number", min: 0 },
209+
},
210+
211+
th: {
212+
"scope": { type: "enum", enum: ["row", "col", "rowgroup", "colgroup"], caseSensitive: false },
213+
"colspan": { type: "number", min: 1 },
214+
"rowspan": { type: "number", min: 1 },
215+
"headers": { type: "space-separated" }, // IDs of th elements
216+
},
217+
218+
td: {
219+
"colspan": { type: "number", min: 1 },
220+
"rowspan": { type: "number", min: 1 },
221+
"headers": { type: "space-separated" }, // IDs of th elements
222+
},
223+
224+
ol: {
225+
"reversed": { type: "boolean" },
226+
"start": { type: "number" },
227+
"type": { type: "enum", enum: ["1", "a", "A", "i", "I"], caseSensitive: true },
228+
},
229+
230+
li: {
231+
"value": { type: "number" },
232+
},
233+
234+
time: {
235+
"datetime": { type: "datetime" },
236+
},
237+
238+
progress: {
239+
"value": { type: "number", min: 0 },
240+
"max": { type: "number", min: 0 },
241+
},
242+
243+
meter: {
244+
"value": { type: "number" },
245+
"min": { type: "number" },
246+
"max": { type: "number" },
247+
"low": { type: "number" },
248+
"high": { type: "number" },
249+
"optimum": { type: "number" },
250+
},
251+
}
252+
253+
export function getAttributeValueRule(elementName: string, attributeName: string): AttributeValueRule | null {
254+
const globalRule = GLOBAL_ATTRIBUTE_TYPES[attributeName.toLowerCase()]
255+
256+
if (globalRule) return globalRule
257+
258+
const elementRules = ELEMENT_ATTRIBUTE_TYPES[elementName.toLowerCase()]
259+
260+
if (elementRules) {
261+
const rule = elementRules[attributeName.toLowerCase()]
262+
263+
if (rule) {
264+
return rule
265+
}
266+
}
267+
268+
return null
269+
}

0 commit comments

Comments
 (0)