@herb-tools/linter 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/herb-lint.js +5131 -1647
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +662 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +654 -147
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/argument-parser.js +0 -4
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/default-rules.js +20 -0
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js +29 -4
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
- package/dist/src/rules/erb-prefer-image-tag-helper.js +0 -4
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
- package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
- package/dist/src/rules/html-aria-level-must-be-valid.js +26 -4
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -13
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
- package/dist/src/rules/html-aria-role-must-be-valid.js +3 -3
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +14 -4
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
- package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
- package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +19 -8
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +9 -2
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-iframe-has-title.js +39 -0
- package/dist/src/rules/html-iframe-has-title.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +0 -4
- package/dist/src/rules/html-img-require-alt.js.map +1 -1
- package/dist/src/rules/html-navigation-has-label.js +43 -0
- package/dist/src/rules/html-navigation-has-label.js.map +1 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
- package/dist/src/rules/html-no-duplicate-attributes.js +22 -25
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +2 -2
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +0 -21
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js +21 -0
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
- package/dist/src/rules/html-no-self-closing.js +22 -0
- package/dist/src/rules/html-no-self-closing.js.map +1 -0
- package/dist/src/rules/html-no-title-attribute.js +27 -0
- package/dist/src/rules/html-no-title-attribute.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +35 -23
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
- package/dist/src/rules/index.js +10 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +176 -22
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/index.d.ts +4 -0
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +2 -1
- package/dist/types/rules/index.d.ts +10 -0
- package/dist/types/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +2 -1
- package/dist/types/src/rules/index.d.ts +10 -0
- package/dist/types/src/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/types.d.ts +24 -0
- package/dist/types/types.d.ts +24 -0
- package/docs/rules/README.md +12 -2
- package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
- package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
- package/docs/rules/html-attribute-equals-spacing.md +35 -0
- package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
- package/docs/rules/html-iframe-has-title.md +43 -0
- package/docs/rules/html-navigation-has-label.md +61 -0
- package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
- package/docs/rules/html-no-positive-tab-index.md +55 -0
- package/docs/rules/html-no-self-closing.md +65 -0
- package/docs/rules/html-no-title-attribute.md +69 -0
- package/docs/rules/html-tag-name-lowercase.md +16 -3
- package/package.json +4 -4
- package/src/cli/argument-parser.ts +0 -5
- package/src/default-rules.ts +20 -0
- package/src/linter.ts +30 -4
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +2 -7
- package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
- package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
- package/src/rules/html-aria-level-must-be-valid.ts +38 -5
- package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
- package/src/rules/html-aria-role-must-be-valid.ts +5 -5
- package/src/rules/html-attribute-double-quotes.ts +21 -6
- package/src/rules/html-attribute-equals-spacing.ts +41 -0
- package/src/rules/html-attribute-values-require-quotes.ts +29 -9
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
- package/src/rules/html-boolean-attributes-no-value.ts +17 -4
- package/src/rules/html-iframe-has-title.ts +62 -0
- package/src/rules/html-img-require-alt.ts +2 -7
- package/src/rules/html-navigation-has-label.ts +64 -0
- package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
- package/src/rules/html-no-duplicate-attributes.ts +28 -28
- package/src/rules/html-no-duplicate-ids.ts +4 -3
- package/src/rules/html-no-empty-headings.ts +2 -31
- package/src/rules/html-no-positive-tab-index.ts +33 -0
- package/src/rules/html-no-self-closing.ts +36 -0
- package/src/rules/html-no-title-attribute.ts +42 -0
- package/src/rules/html-tag-name-lowercase.ts +42 -29
- package/src/rules/index.ts +10 -0
- package/src/rules/rule-utils.ts +260 -39
- package/src/rules/svg-tag-name-capitalization.ts +2 -9
- package/src/types.ts +27 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Linter Rule: Avoid positive `tabindex` values
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-no-positive-tab-index`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Prevent using positive values for the `tabindex` attribute. Only `tabindex="0"` (to make elements focusable) and `tabindex="-1"` (to remove from tab order) should be used.
|
|
8
|
+
|
|
9
|
+
## Rationale
|
|
10
|
+
|
|
11
|
+
Positive `tabindex` values create a custom tab order that can be confusing and unpredictable for keyboard users. They override the natural document flow and can cause elements to be focused in an unexpected sequence. This breaks the logical reading order and creates usability issues, especially for screen reader users who rely on a predictable navigation pattern.
|
|
12
|
+
|
|
13
|
+
The recommended approach is to structure your HTML in the correct tab order and use `tabindex="0"` only when you need to make non-interactive elements focusable, or `tabindex="-1"` to remove elements from the tab sequence while keeping them programmatically focusable.
|
|
14
|
+
|
|
15
|
+
## Examples
|
|
16
|
+
|
|
17
|
+
### ✅ Good
|
|
18
|
+
|
|
19
|
+
```erb
|
|
20
|
+
<!-- Natural tab order (no tabindex needed) -->
|
|
21
|
+
<button>First</button>
|
|
22
|
+
<button>Second</button>
|
|
23
|
+
<button>Third</button>
|
|
24
|
+
|
|
25
|
+
<!-- Make non-interactive element focusable -->
|
|
26
|
+
<div tabindex="0" role="button">Custom button</div>
|
|
27
|
+
|
|
28
|
+
<!-- Remove from tab order but keep programmatically focusable -->
|
|
29
|
+
<button tabindex="-1">Skip this in tab order</button>
|
|
30
|
+
|
|
31
|
+
<!-- Zero tabindex to ensure focusability -->
|
|
32
|
+
<span tabindex="0" role="button">Focusable span</span>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 🚫 Bad
|
|
36
|
+
|
|
37
|
+
```erb
|
|
38
|
+
<button tabindex="3">Third in tab order</button>
|
|
39
|
+
|
|
40
|
+
<button tabindex="1">First in tab order</button>
|
|
41
|
+
|
|
42
|
+
<button tabindex="2">Second in tab order</button>
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
<input tabindex="5" type="text">
|
|
46
|
+
|
|
47
|
+
<button tabindex="10">Submit</button>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## References
|
|
51
|
+
|
|
52
|
+
- [HTML: `tabindex` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex)
|
|
53
|
+
- [WebAIM: Keyboard Accessibility](https://webaim.org/techniques/keyboard/tabindex)
|
|
54
|
+
- [WCAG: Focus Order](https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html)
|
|
55
|
+
- [erblint-github: GitHub::Accessibility::NoPositiveTabIndex](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/no-positive-tab-index.md)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Linter Rule: Disallow self-closing tag syntax for void elements
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-no-self-closing`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Disallow self-closing syntax (`<tag />`) in HTML for all elements.
|
|
8
|
+
|
|
9
|
+
In HTML5, the trailing slash in a start tag is obsolete and has no effect.
|
|
10
|
+
Non-void elements require explicit end tags, and void elements are
|
|
11
|
+
self-contained without the slash.
|
|
12
|
+
|
|
13
|
+
## Rationale
|
|
14
|
+
|
|
15
|
+
Self-closing syntax is an XHTML artifact. In HTML:
|
|
16
|
+
|
|
17
|
+
- On **non-void** elements, it’s a parse error and produces invalid markup
|
|
18
|
+
(`<div />` is invalid).
|
|
19
|
+
- On **void elements**, the slash is ignored and unnecessary (`<input />` is
|
|
20
|
+
equivalent to `<input>`).
|
|
21
|
+
|
|
22
|
+
Removing the slash ensures HTML5-compliant, cleaner markup and avoids mixing
|
|
23
|
+
XHTML and HTML styles.
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
### ✅ Good
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<span></span>
|
|
31
|
+
<div></div>
|
|
32
|
+
<section></section>
|
|
33
|
+
<custom-element></custom-element>
|
|
34
|
+
|
|
35
|
+
<img src="/logo.png" alt="Logo">
|
|
36
|
+
<input type="text">
|
|
37
|
+
<br>
|
|
38
|
+
<hr>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 🚫 Bad
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<span />
|
|
45
|
+
|
|
46
|
+
<div />
|
|
47
|
+
|
|
48
|
+
<section />
|
|
49
|
+
|
|
50
|
+
<custom-element />
|
|
51
|
+
|
|
52
|
+
<img src="/logo.png" alt="Logo" />
|
|
53
|
+
|
|
54
|
+
<input type="text" />
|
|
55
|
+
|
|
56
|
+
<br />
|
|
57
|
+
|
|
58
|
+
<hr />
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## References
|
|
62
|
+
|
|
63
|
+
- [HTML Living Standard: Void Elements](https://html.spec.whatwg.org/multipage/syntax.html#void-elements)
|
|
64
|
+
- [MDN: Void element](https://developer.mozilla.org/en-US/docs/Glossary/Void_element)
|
|
65
|
+
- [erb_lint: SelfClosingTag](https://github.com/Shopify/erb_lint#selfclosingtag)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Linter Rule: Avoid using the `title` attribute
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-no-title-attribute`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Discourage the use of the `title` attribute on most HTML elements, as it provides poor accessibility and user experience. The `title` attribute is only accessible via mouse hover and is not reliably exposed to screen readers or keyboard users.
|
|
8
|
+
|
|
9
|
+
## Rationale
|
|
10
|
+
|
|
11
|
+
The `title` attribute has several accessibility problems:
|
|
12
|
+
- It's only visible on mouse hover, making it inaccessible to keyboard and touch users
|
|
13
|
+
- Screen readers don't consistently announce title attributes
|
|
14
|
+
- Mobile devices don't show title tooltips
|
|
15
|
+
- The visual presentation is inconsistent across browsers and operating systems
|
|
16
|
+
|
|
17
|
+
Instead of relying on `title`, use visible text, `aria-label`, `aria-describedby`, or other accessible alternatives.
|
|
18
|
+
|
|
19
|
+
::: warning Exceptions
|
|
20
|
+
This rule allows `title` on `<iframe>` and `<link>` elements where it serves specific accessibility purposes.
|
|
21
|
+
:::
|
|
22
|
+
|
|
23
|
+
## Examples
|
|
24
|
+
|
|
25
|
+
### ✅ Good
|
|
26
|
+
|
|
27
|
+
```erb
|
|
28
|
+
<!-- Use visible text instead of title -->
|
|
29
|
+
<button>Save document</button>
|
|
30
|
+
<span class="help-text">Click to save your changes</span>
|
|
31
|
+
|
|
32
|
+
<!-- Use aria-label for accessible names -->
|
|
33
|
+
<button aria-label="Close dialog">×</button>
|
|
34
|
+
|
|
35
|
+
<!-- Use aria-describedby for additional context -->
|
|
36
|
+
<input type="password" aria-describedby="pwd-help">
|
|
37
|
+
<div id="pwd-help">Password must be at least 8 characters</div>
|
|
38
|
+
|
|
39
|
+
<!-- Exceptions: title allowed on iframe and links -->
|
|
40
|
+
<iframe src="https://example.com" title="Example website content"></iframe>
|
|
41
|
+
<link href="default.css" rel="stylesheet" title="Default Style">
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 🚫 Bad
|
|
45
|
+
|
|
46
|
+
```erb
|
|
47
|
+
<!-- Don't use title for essential information -->
|
|
48
|
+
<button title="Save your changes">Save</button>
|
|
49
|
+
|
|
50
|
+
<div title="This is important information">Content</div>
|
|
51
|
+
|
|
52
|
+
<span title="Required field">*</span>
|
|
53
|
+
|
|
54
|
+
<!-- Don't use title on form elements -->
|
|
55
|
+
<input type="text" title="Enter your name">
|
|
56
|
+
|
|
57
|
+
<select title="Choose your country">
|
|
58
|
+
<option>US</option>
|
|
59
|
+
<option>CA</option>
|
|
60
|
+
</select>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## References
|
|
64
|
+
|
|
65
|
+
- [HTML: `title` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
|
|
66
|
+
- [WebAIM: Accessible Forms](https://webaim.org/techniques/forms/)
|
|
67
|
+
- [ARIA: `aria-label` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label)
|
|
68
|
+
- [ARIA: `aria-describedby` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby)
|
|
69
|
+
- [erblint-github: GitHub::Accessibility::NoTitleAttribute](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/no-title-attribute.md)
|
|
@@ -12,15 +12,28 @@ HTML is case-insensitive for tag names, but lowercase is the widely accepted con
|
|
|
12
12
|
|
|
13
13
|
Writing tags in uppercase or mixed case can lead to inconsistent code and unnecessary diffs during reviews and merges.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### Notes
|
|
16
|
+
|
|
17
|
+
::: tip XML Documents
|
|
18
|
+
This rule is automatically disabled for XML documents and XML+ERB templates. XML allows uppercase tag names and follows different naming conventions than HTML.
|
|
19
|
+
|
|
20
|
+
The rule will be disabled when:
|
|
21
|
+
- The document contains an XML declaration (`<?xml version="1.0" ?>`)
|
|
22
|
+
- The file extension is `.xml` or `.xml.erb`
|
|
23
|
+
:::
|
|
16
24
|
|
|
25
|
+
::: tip SVG Elements
|
|
26
|
+
This rule does not apply to child elements within `<svg>` tags, as SVG element names are case-sensitive and may require specific capitalization (e.g., `linearGradient`, `clipPath`). However, the rule still applies to the `<svg>` element itself.
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
17
30
|
|
|
18
31
|
### ✅ Good
|
|
19
32
|
|
|
20
33
|
```erb
|
|
21
34
|
<div class="container"></div>
|
|
22
35
|
|
|
23
|
-
<input type="text" name="username"
|
|
36
|
+
<input type="text" name="username">
|
|
24
37
|
|
|
25
38
|
<span>Label</span>
|
|
26
39
|
|
|
@@ -32,7 +45,7 @@ Writing tags in uppercase or mixed case can lead to inconsistent code and unnece
|
|
|
32
45
|
```erb
|
|
33
46
|
<DIV class="container"></DIV>
|
|
34
47
|
|
|
35
|
-
<Input type="text" name="username"
|
|
48
|
+
<Input type="text" name="username">
|
|
36
49
|
|
|
37
50
|
<Span>Label</Span>
|
|
38
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herb-tools/linter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "HTML+ERB linter for validating HTML structure and enforcing best practices",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://herb-tools.dev",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@herb-tools/core": "0.
|
|
37
|
-
"@herb-tools/highlighter": "0.
|
|
38
|
-
"@herb-tools/node-wasm": "0.
|
|
36
|
+
"@herb-tools/core": "0.6.0",
|
|
37
|
+
"@herb-tools/highlighter": "0.6.0",
|
|
38
|
+
"@herb-tools/node-wasm": "0.6.0",
|
|
39
39
|
"glob": "^11.0.3"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
@@ -114,11 +114,6 @@ export class ArgumentParser {
|
|
|
114
114
|
const theme = values.theme || DEFAULT_THEME
|
|
115
115
|
const pattern = this.getFilePattern(positionals)
|
|
116
116
|
|
|
117
|
-
if (positionals.length === 0) {
|
|
118
|
-
console.error("Please specify input file.")
|
|
119
|
-
process.exit(1)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
117
|
return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines }
|
|
123
118
|
}
|
|
124
119
|
|
package/src/default-rules.ts
CHANGED
|
@@ -2,23 +2,33 @@ import type { RuleClass } from "./types.js"
|
|
|
2
2
|
|
|
3
3
|
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
|
|
4
4
|
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
|
|
5
|
+
import { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
|
|
5
6
|
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
|
|
6
7
|
import { ERBRequiresTrailingNewlineRule } from "./rules/erb-requires-trailing-newline.js"
|
|
7
8
|
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
|
|
8
9
|
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
|
|
9
10
|
import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
|
|
11
|
+
import { HTMLAriaLabelIsWellFormattedRule } from "./rules/html-aria-label-is-well-formatted.js"
|
|
10
12
|
import { HTMLAriaLevelMustBeValidRule } from "./rules/html-aria-level-must-be-valid.js"
|
|
11
13
|
import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
|
|
12
14
|
import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
|
|
13
15
|
import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
|
|
16
|
+
import { HTMLAttributeEqualsSpacingRule } from "./rules/html-attribute-equals-spacing.js"
|
|
14
17
|
import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-values-require-quotes.js"
|
|
18
|
+
import { HTMLAvoidBothDisabledAndAriaDisabledRule } from "./rules/html-avoid-both-disabled-and-aria-disabled.js"
|
|
15
19
|
import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js"
|
|
20
|
+
import { HTMLIframeHasTitleRule } from "./rules/html-iframe-has-title.js"
|
|
16
21
|
import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
|
|
22
|
+
import { HTMLNavigationHasLabelRule } from "./rules/html-navigation-has-label.js"
|
|
23
|
+
import { HTMLNoAriaHiddenOnFocusableRule } from "./rules/html-no-aria-hidden-on-focusable.js"
|
|
17
24
|
// import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
|
|
18
25
|
import { HTMLNoDuplicateAttributesRule } from "./rules/html-no-duplicate-attributes.js"
|
|
19
26
|
import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
|
|
20
27
|
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
|
|
21
28
|
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
|
|
29
|
+
import { HTMLNoPositiveTabIndexRule } from "./rules/html-no-positive-tab-index.js"
|
|
30
|
+
import { HTMLNoSelfClosingRule } from "./rules/html-no-self-closing.js"
|
|
31
|
+
import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
|
|
22
32
|
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
|
|
23
33
|
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
|
|
24
34
|
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
|
|
@@ -26,23 +36,33 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio
|
|
|
26
36
|
export const defaultRules: RuleClass[] = [
|
|
27
37
|
ERBNoEmptyTagsRule,
|
|
28
38
|
ERBNoOutputControlFlowRule,
|
|
39
|
+
ERBNoSilentTagInAttributeNameRule,
|
|
29
40
|
ERBPreferImageTagHelperRule,
|
|
30
41
|
ERBRequiresTrailingNewlineRule,
|
|
31
42
|
ERBRequireWhitespaceRule,
|
|
32
43
|
HTMLAnchorRequireHrefRule,
|
|
33
44
|
HTMLAriaAttributeMustBeValid,
|
|
45
|
+
HTMLAriaLabelIsWellFormattedRule,
|
|
34
46
|
HTMLAriaLevelMustBeValidRule,
|
|
35
47
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
36
48
|
HTMLAriaRoleMustBeValidRule,
|
|
37
49
|
HTMLAttributeDoubleQuotesRule,
|
|
50
|
+
HTMLAttributeEqualsSpacingRule,
|
|
38
51
|
HTMLAttributeValuesRequireQuotesRule,
|
|
52
|
+
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
39
53
|
HTMLBooleanAttributesNoValueRule,
|
|
54
|
+
HTMLIframeHasTitleRule,
|
|
40
55
|
HTMLImgRequireAltRule,
|
|
56
|
+
HTMLNavigationHasLabelRule,
|
|
57
|
+
HTMLNoAriaHiddenOnFocusableRule,
|
|
41
58
|
// HTMLNoBlockInsideInlineRule,
|
|
42
59
|
HTMLNoDuplicateAttributesRule,
|
|
43
60
|
HTMLNoDuplicateIdsRule,
|
|
44
61
|
HTMLNoEmptyHeadingsRule,
|
|
45
62
|
HTMLNoNestedLinksRule,
|
|
63
|
+
HTMLNoPositiveTabIndexRule,
|
|
64
|
+
HTMLNoSelfClosingRule,
|
|
65
|
+
HTMLNoTitleAttributeRule,
|
|
46
66
|
HTMLTagNameLowercaseRule,
|
|
47
67
|
ParserNoErrorsRule,
|
|
48
68
|
SVGTagNameCapitalizationRule,
|
package/src/linter.ts
CHANGED
|
@@ -53,20 +53,46 @@ export class Linter {
|
|
|
53
53
|
lint(source: string, context?: Partial<LintContext>): LintResult {
|
|
54
54
|
this.offenses = []
|
|
55
55
|
|
|
56
|
-
const parseResult = this.herb.parse(source)
|
|
56
|
+
const parseResult = this.herb.parse(source, { track_whitespace: true })
|
|
57
57
|
const lexResult = this.herb.lex(source)
|
|
58
58
|
|
|
59
59
|
for (const RuleClass of this.rules) {
|
|
60
60
|
const rule = new RuleClass()
|
|
61
61
|
|
|
62
|
+
let isEnabled = true
|
|
62
63
|
let ruleOffenses: LintOffense[]
|
|
63
64
|
|
|
64
65
|
if (this.isLexerRule(rule)) {
|
|
65
|
-
|
|
66
|
+
if (rule.isEnabled) {
|
|
67
|
+
isEnabled = rule.isEnabled(lexResult, context)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isEnabled) {
|
|
71
|
+
ruleOffenses = (rule as LexerRule).check(lexResult, context)
|
|
72
|
+
} else {
|
|
73
|
+
ruleOffenses = []
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
} else if (this.isSourceRule(rule)) {
|
|
67
|
-
|
|
77
|
+
if (rule.isEnabled) {
|
|
78
|
+
isEnabled = rule.isEnabled(source, context)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isEnabled) {
|
|
82
|
+
ruleOffenses = (rule as SourceRule).check(source, context)
|
|
83
|
+
} else {
|
|
84
|
+
ruleOffenses = []
|
|
85
|
+
}
|
|
68
86
|
} else {
|
|
69
|
-
|
|
87
|
+
if (rule.isEnabled) {
|
|
88
|
+
isEnabled = rule.isEnabled(parseResult, context)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isEnabled) {
|
|
92
|
+
ruleOffenses = (rule as ParserRule).check(parseResult, context)
|
|
93
|
+
} else {
|
|
94
|
+
ruleOffenses = []
|
|
95
|
+
}
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
this.offenses.push(...ruleOffenses)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js"
|
|
2
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
3
|
+
import { filterERBContentNodes } from "@herb-tools/core"
|
|
4
|
+
|
|
5
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
6
|
+
import type { ParseResult, HTMLAttributeNameNode, ERBContentNode } from "@herb-tools/core"
|
|
7
|
+
|
|
8
|
+
class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
|
|
9
|
+
visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void {
|
|
10
|
+
const erbNodes = filterERBContentNodes(node.children)
|
|
11
|
+
const silentNodes = erbNodes.filter(this.isSilentERBTag)
|
|
12
|
+
|
|
13
|
+
for (const node of silentNodes) {
|
|
14
|
+
this.addOffense(
|
|
15
|
+
`Remove silent ERB tag from HTML attribute name. Silent ERB tags (\`${node.tag_opening?.value}\`) do not output content and should not be used in attribute names.`,
|
|
16
|
+
node.location,
|
|
17
|
+
"error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// TODO: might be worth to extract
|
|
23
|
+
private isSilentERBTag(node: ERBContentNode): boolean {
|
|
24
|
+
const silentTags = ["<%", "<%-", "<%#"]
|
|
25
|
+
|
|
26
|
+
return silentTags.includes(node.tag_opening?.value || "")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
31
|
+
name = "erb-no-silent-tag-in-attribute-name"
|
|
32
|
+
|
|
33
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
34
|
+
const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context)
|
|
35
|
+
|
|
36
|
+
visitor.visit(result.value)
|
|
37
|
+
|
|
38
|
+
return visitor.offenses
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from
|
|
|
2
2
|
|
|
3
3
|
import { ParserRule } from "../types.js"
|
|
4
4
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { HTMLOpenTagNode,
|
|
5
|
+
import type { HTMLOpenTagNode, HTMLAttributeValueNode, ERBContentNode, LiteralNode, ParseResult } from "@herb-tools/core"
|
|
6
6
|
|
|
7
7
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
8
8
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
@@ -10,12 +10,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
10
10
|
super.visitHTMLOpenTagNode(node)
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
this.checkImgTag(node)
|
|
15
|
-
super.visitHTMLSelfCloseTagNode(node)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
13
|
+
private checkImgTag(node: HTMLOpenTagNode): void {
|
|
19
14
|
const tagName = getTagName(node)
|
|
20
15
|
|
|
21
16
|
if (tagName !== "img") {
|
|
@@ -1,42 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "
|
|
5
|
-
import {
|
|
6
|
-
import type { LintOffense, LintContext } from "../types.js";
|
|
7
|
-
import type {
|
|
8
|
-
HTMLAttributeNode,
|
|
9
|
-
HTMLOpenTagNode,
|
|
10
|
-
HTMLSelfCloseTagNode,
|
|
11
|
-
ParseResult,
|
|
12
|
-
} from "@herb-tools/core";
|
|
1
|
+
import { ParserRule } from "../types.js"
|
|
2
|
+
import { ARIA_ATTRIBUTES, AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
|
|
3
|
+
|
|
4
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
+
import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
|
|
13
6
|
|
|
14
7
|
class AriaAttributeMustBeValid extends AttributeVisitorMixin {
|
|
15
|
-
|
|
16
|
-
attributeName
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
8
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams) {
|
|
9
|
+
this.check(attributeName, attributeNode)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
protected checkStaticAttributeDynamicValue({ attributeName, attributeNode }: StaticAttributeDynamicValueParams) {
|
|
13
|
+
this.check(attributeName, attributeNode)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private check(attributeName: string, attributeNode: HTMLAttributeNode) {
|
|
17
|
+
if (!attributeName.startsWith("aria-")) return
|
|
18
|
+
if (ARIA_ATTRIBUTES.has(attributeName)) return
|
|
19
|
+
|
|
20
|
+
this.addOffense(
|
|
21
|
+
`The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`,
|
|
22
|
+
attributeNode.location,
|
|
23
|
+
"error"
|
|
24
|
+
)
|
|
31
25
|
}
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
export class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
35
|
-
name = "html-aria-attribute-must-be-valid"
|
|
29
|
+
name = "html-aria-attribute-must-be-valid"
|
|
36
30
|
|
|
37
31
|
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
38
|
-
const visitor = new AriaAttributeMustBeValid(this.name, context)
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
const visitor = new AriaAttributeMustBeValid(this.name, context)
|
|
33
|
+
|
|
34
|
+
visitor.visit(result.value)
|
|
35
|
+
|
|
36
|
+
return visitor.offenses
|
|
41
37
|
}
|
|
42
38
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js"
|
|
2
|
+
import { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils.js"
|
|
3
|
+
|
|
4
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
+
import type { ParseResult } from "@herb-tools/core"
|
|
6
|
+
|
|
7
|
+
class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
|
|
8
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
|
|
9
|
+
if (attributeName !== "aria-label") return
|
|
10
|
+
|
|
11
|
+
if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/ | |
|
/i)) {
|
|
12
|
+
this.addOffense(
|
|
13
|
+
"The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.",
|
|
14
|
+
attributeNode.location,
|
|
15
|
+
"error"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (this.looksLikeId(attributeValue)) {
|
|
22
|
+
this.addOffense(
|
|
23
|
+
"The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.",
|
|
24
|
+
attributeNode.location,
|
|
25
|
+
"error"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (attributeValue.match(/^[a-z]/)) {
|
|
32
|
+
this.addOffense(
|
|
33
|
+
"The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).",
|
|
34
|
+
attributeNode.location,
|
|
35
|
+
"error"
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private looksLikeId(text: string): boolean {
|
|
41
|
+
return (
|
|
42
|
+
text.includes('_') ||
|
|
43
|
+
text.includes('-') ||
|
|
44
|
+
/^[a-z]+([A-Z][a-z]*)*$/.test(text)
|
|
45
|
+
) && !text.includes(' ')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
|
|
50
|
+
name = "html-aria-label-is-well-formatted"
|
|
51
|
+
|
|
52
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
53
|
+
const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context)
|
|
54
|
+
|
|
55
|
+
visitor.visit(result.value)
|
|
56
|
+
|
|
57
|
+
return visitor.offenses
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,15 +1,48 @@
|
|
|
1
|
-
import { AttributeVisitorMixin } from "./rule-utils.js"
|
|
2
1
|
import { ParserRule } from "../types.js"
|
|
2
|
+
import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
|
|
3
|
+
import { getValidatableStaticContent, hasERBOutput, filterLiteralNodes, filterERBContentNodes, isERBOutputNode } from "@herb-tools/core"
|
|
3
4
|
|
|
4
5
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { ParseResult, HTMLAttributeNode
|
|
6
|
+
import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
|
|
6
7
|
|
|
7
8
|
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
8
|
-
protected
|
|
9
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams) {
|
|
9
10
|
if (attributeName !== "aria-level") return
|
|
10
|
-
if (attributeValue !== null && attributeValue.includes("<%")) return
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
this.validateAriaLevel(attributeValue, attributeNode)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
protected checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }: StaticAttributeDynamicValueParams) {
|
|
16
|
+
if (attributeName !== "aria-level") return
|
|
17
|
+
|
|
18
|
+
const validatableContent = getValidatableStaticContent(valueNodes)
|
|
19
|
+
|
|
20
|
+
if (validatableContent !== null) {
|
|
21
|
+
this.validateAriaLevel(validatableContent, attributeNode)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!hasERBOutput(valueNodes)) return
|
|
26
|
+
|
|
27
|
+
const literalNodes = filterLiteralNodes(valueNodes)
|
|
28
|
+
const erbOutputNodes = filterERBContentNodes(valueNodes).filter(isERBOutputNode)
|
|
29
|
+
|
|
30
|
+
if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
|
|
31
|
+
const staticPart = literalNodes.map(node => node.content).join("")
|
|
32
|
+
|
|
33
|
+
// TODO: this can be cleaned up using @herb-tools/printer
|
|
34
|
+
const erbPart = erbOutputNodes[0]
|
|
35
|
+
const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`
|
|
36
|
+
|
|
37
|
+
this.addOffense(
|
|
38
|
+
`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`,
|
|
39
|
+
attributeNode.location,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private validateAriaLevel(attributeValue: string, attributeNode: HTMLAttributeNode): void {
|
|
45
|
+
if (!attributeValue || attributeValue === "") {
|
|
13
46
|
this.addOffense(
|
|
14
47
|
`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`,
|
|
15
48
|
attributeNode.location,
|