@herb-tools/linter 0.4.0 → 0.4.2

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.
Files changed (54) hide show
  1. package/README.md +2 -4
  2. package/dist/herb-lint.js +292 -107
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +351 -77
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +348 -78
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +4 -4
  9. package/dist/src/default-rules.js +10 -2
  10. package/dist/src/default-rules.js.map +1 -1
  11. package/dist/src/rules/erb-require-whitespace-inside-tags.js +18 -2
  12. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  13. package/dist/src/rules/html-aria-attribute-must-be-valid.js +24 -0
  14. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -0
  15. package/dist/src/rules/html-aria-role-must-be-valid.js +21 -0
  16. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -0
  17. package/dist/src/rules/html-no-duplicate-ids.js +25 -0
  18. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -0
  19. package/dist/src/rules/html-tag-name-lowercase.js +17 -8
  20. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  21. package/dist/src/rules/index.js +4 -0
  22. package/dist/src/rules/index.js.map +1 -1
  23. package/dist/src/rules/rule-utils.js +126 -8
  24. package/dist/src/rules/rule-utils.js.map +1 -1
  25. package/dist/src/rules/svg-tag-name-capitalization.js +57 -0
  26. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -0
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +6 -0
  29. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +6 -0
  30. package/dist/types/rules/html-no-duplicate-ids.d.ts +6 -0
  31. package/dist/types/rules/index.d.ts +4 -0
  32. package/dist/types/rules/rule-utils.d.ts +11 -0
  33. package/dist/types/rules/svg-tag-name-capitalization.d.ts +6 -0
  34. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +6 -0
  35. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +6 -0
  36. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +6 -0
  37. package/dist/types/src/rules/index.d.ts +4 -0
  38. package/dist/types/src/rules/rule-utils.d.ts +11 -0
  39. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +6 -0
  40. package/docs/rules/README.md +5 -0
  41. package/docs/rules/html-aria-attribute-must-be-valid.md +45 -0
  42. package/docs/rules/html-aria-role-must-be-valid.md +45 -0
  43. package/docs/rules/html-no-duplicate-ids.md +49 -0
  44. package/docs/rules/svg-tag-name-capitalization.md +57 -0
  45. package/package.json +4 -4
  46. package/src/default-rules.ts +10 -2
  47. package/src/rules/erb-require-whitespace-inside-tags.ts +33 -2
  48. package/src/rules/html-aria-attribute-must-be-valid.ts +42 -0
  49. package/src/rules/html-aria-role-must-be-valid.ts +30 -0
  50. package/src/rules/html-no-duplicate-ids.ts +39 -0
  51. package/src/rules/html-tag-name-lowercase.ts +24 -9
  52. package/src/rules/index.ts +4 -0
  53. package/src/rules/rule-utils.ts +145 -17
  54. package/src/rules/svg-tag-name-capitalization.ts +73 -0
@@ -56,6 +56,17 @@ export declare const HTML_INLINE_ELEMENTS: Set<string>;
56
56
  export declare const HTML_BLOCK_ELEMENTS: Set<string>;
57
57
  export declare const HTML_BOOLEAN_ATTRIBUTES: Set<string>;
58
58
  export declare const HEADING_TAGS: Set<string>;
59
+ /**
60
+ * SVG elements that use camelCase naming
61
+ */
62
+ export declare const SVG_CAMEL_CASE_ELEMENTS: Set<string>;
63
+ /**
64
+ * Mapping from lowercase SVG element names to their correct camelCase versions
65
+ * Generated dynamically from SVG_CAMEL_CASE_ELEMENTS
66
+ */
67
+ export declare const SVG_LOWERCASE_TO_CAMELCASE: Map<string, string>;
68
+ export declare const VALID_ARIA_ROLES: Set<string>;
69
+ export declare const ARIA_ATTRIBUTES: Set<string>;
59
70
  /**
60
71
  * Checks if an element is inline
61
72
  */
@@ -0,0 +1,6 @@
1
+ import type { Rule, LintOffense } from "../types.js";
2
+ import type { Node } from "@herb-tools/core";
3
+ export declare class SVGTagNameCapitalizationRule implements Rule {
4
+ name: string;
5
+ check(node: Node): LintOffense[];
6
+ }
@@ -7,15 +7,20 @@ This page contains documentation for all Herb Linter rules.
7
7
  - [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
8
8
  - [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
9
9
  - [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around erb tags
10
+ - [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
11
+ - [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
10
12
  - [`html-aria-role-heading-requires-level`](./html-aria-role-heading-requires-level.md) - Requires `aria-level` when supplying a `role`
13
+ - [`html-aria-role-must-be-valid`](./html-aria-role-must-be-valid.md) - The `role` attribute must have a valid WAI-ARIA Role.
11
14
  - [`html-attribute-double-quotes`](./html-attribute-double-quotes.md) - Enforces double quotes for attribute values
12
15
  - [`html-attribute-values-require-quotes`](./html-attribute-values-require-quotes.md) - Requires quotes around attribute values
13
16
  - [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes
14
17
  - [`html-img-require-alt`](./html-img-require-alt.md) - Requires alt attributes on img tags
15
18
  - [`html-no-block-inside-inline`](./html-no-block-inside-inline.md) - Prevents block-level elements inside inline elements
19
+ - [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
16
20
  - [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
17
21
  - [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
18
22
  - [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
23
+ - [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
19
24
 
20
25
  ## Contributing
21
26
 
@@ -0,0 +1,45 @@
1
+ # Linter Rule: Disallow invalid or unknown `aria-*` attributes.
2
+
3
+ **Rule:** `html-aria-attribute-must-be-valid`
4
+
5
+ ## Description
6
+
7
+ Disallow unknown or invalid `aria-*` attributes. Only attributes defined in the WAI-ARIA specification should be used. This rule helps catch typos (e.g. `aria-lable`), misuse, or outdated attribute names that won't be interpreted by assistive technologies.
8
+
9
+ ## Rationale
10
+
11
+ ARIA attributes are powerful accessibility tools, but **only if used correctly**. Mistyped or unsupported attributes:
12
+
13
+ - Are silently ignored by browsers and screen readers
14
+ - Fail to communicate intent
15
+ - Give a false sense of accessibility
16
+
17
+ Validating against a known list ensures you're using correct and effective ARIA patterns.
18
+
19
+ ## Examples
20
+
21
+ ### ✅ Good
22
+
23
+ ```html
24
+ <div role="button" aria-pressed="false">Toggle</div>
25
+ <input type="text" aria-label="Search" />
26
+ <span role="heading" aria-level="2">Title</span>
27
+ ```
28
+
29
+ ### 🚫 Bad
30
+
31
+ ```html
32
+ <!-- typo -->
33
+ <div role="button" aria-presed="false">Toggle</div>
34
+
35
+ <!-- typo -->
36
+ <input type="text" aria-lable="Search" />
37
+
38
+ <!-- invalid -->
39
+ <span aria-size="large" role="heading" aria-level="2">Title</span>
40
+ ```
41
+
42
+ ## References
43
+
44
+ - [ARIA states and properties (attributes)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes)
45
+ - [NPM Package: `aria-attributes`](https://github.com/wooorm/aria-attributes)
@@ -0,0 +1,45 @@
1
+ # Linter Rule: Disallow invalid values for the `role` attribute
2
+
3
+ **Rule:** `html-aria-role-must-be-valid`
4
+
5
+ ## Description
6
+
7
+ Disallow invalid or unknown values for the `role` attribute. The `role` attribute must match one of the recognized ARIA role values as defined by the [WAI-ARIA specification](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles).
8
+
9
+ ## Rationale
10
+
11
+ ARIA `role` attributes are used to define the purpose of an element to assistive technologies. Using invalid, misspelled, or non-standard roles results in:
12
+
13
+ * Screen readers ignoring the role
14
+ * Broken accessibility semantics
15
+ * False sense of correctness
16
+
17
+ Validating against the official list of ARIA roles prevents silent accessibility failures.
18
+
19
+ ## Examples
20
+
21
+ ### ✅ Good
22
+
23
+ ```html
24
+ <div role="button">Click me</div>
25
+ <nav role="navigation">...</nav>
26
+ <section role="region">...</section>
27
+ ```
28
+
29
+ ### 🚫 Bad
30
+
31
+ ```html
32
+ <!-- typo -->
33
+ <div role="buton">Click me</div>
34
+
35
+ <!-- not a valid role -->
36
+ <nav role="nav">...</nav>
37
+
38
+ <!-- not in the ARIA spec -->
39
+ <section role="header">...</section>
40
+ ```
41
+
42
+ ## References
43
+
44
+ * [ARIA 1.2 Specification - Roles](https://www.w3.org/TR/wai-aria/#roles)
45
+ * [MDN: ARIA roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles)
@@ -0,0 +1,49 @@
1
+ # Linter Rule: Disallow duplicate IDs in the same document
2
+
3
+ **Rule:** `html-no-duplicate-ids`
4
+
5
+ ## Description
6
+
7
+ Ensure that `id` attribute is unique within a document.
8
+
9
+ ## Rationale
10
+
11
+ Duplicate IDs in an HTML document can lead to unexpected behavior, especially when using JavaScript or CSS that relies on unique identifiers. Browsers may not handle duplicate IDs consistently, which can cause issues with element selection, styling, and event handling.
12
+
13
+ ## Examples
14
+
15
+ ### ✅ Good
16
+
17
+ ```html
18
+ <div id="header">Header</div>
19
+ <div id="main-content">Main Content</div>
20
+ <div id="footer">Footer</div>
21
+ ```
22
+
23
+ ```erb
24
+ <div id="<%= dom_id("header") %>">Header</div>
25
+ <div id="<%= dom_id("main_content") %>">Main Content</div>
26
+ <div id="<%= dom_id("footer") %>">Footer</div>
27
+ ```
28
+
29
+ ### 🚫 Bad
30
+
31
+ ```html
32
+ <div id="header">Header</div>
33
+
34
+ <div id="header">Duplicate Header</div>
35
+
36
+ <div id="footer">Footer</div>
37
+ ```
38
+
39
+ ```erb
40
+ <div id="<%= dom_id("header") %>">Header</div>
41
+
42
+ <div id="<%= dom_id("header") %>">Duplicate Header</div>
43
+
44
+ <div id="<%= dom_id("footer") %>">Footer</div>
45
+ ```
46
+
47
+ ## References
48
+ * [W3 org - The id attribute](https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#the-id-attribute)
49
+ * [Rails `ActionView::RecordIdentifier#dom_id`](https://api.rubyonrails.org/classes/ActionView/RecordIdentifier.html#method-i-dom_id)
@@ -0,0 +1,57 @@
1
+ # Linter Rule: SVG tag name capitalization
2
+
3
+ **Rule:** `svg-tag-name-capitalization`
4
+
5
+ ## Description
6
+
7
+ Enforces proper camelCase capitalization for SVG element names within SVG contexts.
8
+
9
+ ## Rationale
10
+
11
+ SVG elements use camelCase naming conventions (e.g., `linearGradient`, `clipPath`, `feGaussianBlur`) rather than the lowercase conventions used in HTML. This rule ensures that SVG elements within `<svg>` tags use the correct capitalization for proper rendering and standards compliance.
12
+
13
+ This rule only applies to elements within SVG contexts and does not check the `<svg>` tag itself (that's handled by the `html-tag-name-lowercase` rule).
14
+
15
+ ## Examples
16
+
17
+ ### ✅ Good
18
+
19
+ ```html
20
+ <svg>
21
+ <linearGradient id="grad1">
22
+ <stop offset="0%" stop-color="rgb(255,255,0)" />
23
+ </linearGradient>
24
+ </svg>
25
+ ```
26
+
27
+ ```html
28
+ <svg>
29
+ <clipPath id="clip">
30
+ <rect width="100" height="100" />
31
+ </clipPath>
32
+ <feGaussianBlur stdDeviation="5" />
33
+ </svg>
34
+ ```
35
+
36
+ ### 🚫 Bad
37
+
38
+ ```html
39
+ <svg>
40
+ <lineargradient id="grad1">
41
+ <stop offset="0%" stop-color="rgb(255,255,0)" />
42
+ </lineargradient>
43
+ </svg>
44
+ ```
45
+
46
+ ```html
47
+ <svg>
48
+ <CLIPPATH id="clip">
49
+ <rect width="100" height="100" />
50
+ </CLIPPATH>
51
+ </svg>
52
+ ```
53
+
54
+ ## References
55
+
56
+ * [SVG Element Reference](https://developer.mozilla.org/en-US/docs/Web/SVG/Element)
57
+ * [SVG Naming Conventions](https://www.w3.org/TR/SVG2/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/linter",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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.4.0",
37
- "@herb-tools/highlighter": "0.4.0",
38
- "@herb-tools/node-wasm": "0.4.0",
36
+ "@herb-tools/core": "0.4.2",
37
+ "@herb-tools/highlighter": "0.4.2",
38
+ "@herb-tools/node-wasm": "0.4.2",
39
39
  "glob": "^11.0.3"
40
40
  },
41
41
  "files": [
@@ -4,30 +4,38 @@ import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
4
4
  import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
5
5
  import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
6
6
  import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
7
+ import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
7
8
  import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
9
+ import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
8
10
  import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
9
11
  import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-values-require-quotes.js"
10
12
  import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js"
11
13
  import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
12
- import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
14
+ // import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
13
15
  import { HTMLNoDuplicateAttributesRule } from "./rules/html-no-duplicate-attributes.js"
16
+ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
14
17
  import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
15
18
  import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
16
19
  import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
20
+ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
17
21
 
18
22
  export const defaultRules: RuleClass[] = [
19
23
  ERBNoEmptyTagsRule,
20
24
  ERBNoOutputControlFlowRule,
21
25
  ERBRequireWhitespaceRule,
22
26
  HTMLAnchorRequireHrefRule,
27
+ HTMLAriaAttributeMustBeValid,
23
28
  HTMLAriaRoleHeadingRequiresLevelRule,
29
+ HTMLAriaRoleMustBeValidRule,
24
30
  HTMLAttributeDoubleQuotesRule,
25
31
  HTMLAttributeValuesRequireQuotesRule,
26
32
  HTMLBooleanAttributesNoValueRule,
27
33
  HTMLImgRequireAltRule,
28
- HTMLNoBlockInsideInlineRule,
34
+ // HTMLNoBlockInsideInlineRule,
29
35
  HTMLNoDuplicateAttributesRule,
36
+ HTMLNoDuplicateIdsRule,
30
37
  HTMLNoEmptyHeadingsRule,
31
38
  HTMLNoNestedLinksRule,
32
39
  HTMLTagNameLowercaseRule,
40
+ SVGTagNameCapitalizationRule,
33
41
  ]
@@ -24,14 +24,43 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
24
24
 
25
25
  const value = content.value
26
26
 
27
- this.checkOpenTagWhitespace(openTag, value)
28
- this.checkCloseTagWhitespace(closeTag, value)
27
+ if (openTag.value === "<%#") {
28
+ this.checkCommentTagWhitespace(openTag, closeTag, value)
29
+ } else {
30
+ this.checkOpenTagWhitespace(openTag, value)
31
+ this.checkCloseTagWhitespace(closeTag, value)
32
+ }
33
+ }
34
+
35
+ private checkCommentTagWhitespace(openTag: Token, closeTag: Token, content: string): void {
36
+ if (!content.startsWith(" ") && !content.startsWith("\n") && !content.startsWith("=")) {
37
+ this.addOffense(
38
+ `Add whitespace after \`${openTag.value}\`.`,
39
+ openTag.location,
40
+ "error"
41
+ )
42
+ } else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
43
+ this.addOffense(
44
+ `Add whitespace after \`<%#=\`.`,
45
+ openTag.location,
46
+ "error"
47
+ )
48
+ }
49
+
50
+ if (!content.endsWith(" ") && !content.endsWith("\n")) {
51
+ this.addOffense(
52
+ `Add whitespace before \`${closeTag.value}\`.`,
53
+ closeTag.location,
54
+ "error"
55
+ )
56
+ }
29
57
  }
30
58
 
31
59
  private checkOpenTagWhitespace(openTag: Token, content:string):void {
32
60
  if (content.startsWith(" ") || content.startsWith("\n")) {
33
61
  return
34
62
  }
63
+
35
64
  this.addOffense(
36
65
  `Add whitespace after \`${openTag.value}\`.`,
37
66
  openTag.location,
@@ -43,6 +72,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
43
72
  if (content.endsWith(" ") || content.endsWith("\n")) {
44
73
  return
45
74
  }
75
+
46
76
  this.addOffense(
47
77
  `Add whitespace before \`${closeTag.value}\`.`,
48
78
  closeTag.location,
@@ -53,6 +83,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
53
83
 
54
84
  export class ERBRequireWhitespaceRule implements Rule {
55
85
  name = "erb-require-whitespace-inside-tags"
86
+
56
87
  check(node: Node): LintOffense[] {
57
88
  const visitor = new RequireWhitespaceInsideTags(this.name)
58
89
  visitor.visit(node)
@@ -0,0 +1,42 @@
1
+ import {
2
+ ARIA_ATTRIBUTES,
3
+ AttributeVisitorMixin,
4
+ } from "./rule-utils.js";
5
+
6
+ import type { LintOffense, Rule } from "../types.js";
7
+ import type {
8
+ HTMLAttributeNode,
9
+ HTMLOpenTagNode,
10
+ HTMLSelfCloseTagNode,
11
+ Node,
12
+ } from "@herb-tools/core";
13
+
14
+ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
15
+ checkAttribute(
16
+ attributeName: string,
17
+ _attributeValue: string | null,
18
+ attributeNode: HTMLAttributeNode,
19
+ _parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode,
20
+ ): void {
21
+ if (!attributeName.startsWith("aria-")) return;
22
+
23
+ if (!ARIA_ATTRIBUTES.has(attributeName)){
24
+ this.offenses.push({
25
+ message: `The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`,
26
+ severity: "error",
27
+ location: attributeNode.location,
28
+ rule: this.ruleName,
29
+ });
30
+ }
31
+ }
32
+ }
33
+
34
+ export class HTMLAriaAttributeMustBeValid implements Rule {
35
+ name = "html-aria-attribute-must-be-valid";
36
+
37
+ check(node: Node): LintOffense[] {
38
+ const visitor = new AriaAttributeMustBeValid(this.name);
39
+ visitor.visit(node);
40
+ return visitor.offenses;
41
+ }
42
+ }
@@ -0,0 +1,30 @@
1
+ import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./rule-utils.js"
2
+
3
+ import type { Rule, LintOffense } from "../types.js"
4
+ import type { Node, HTMLAttributeNode } from "@herb-tools/core"
5
+
6
+ class AriaRoleMustBeValid extends AttributeVisitorMixin {
7
+ checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode,): void {
8
+ if (attributeName !== "role") return
9
+ if (attributeValue === null) return
10
+ if (VALID_ARIA_ROLES.has(attributeValue)) return
11
+
12
+ this.addOffense(
13
+ `The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`,
14
+ attributeNode.location,
15
+ "error"
16
+ )
17
+ }
18
+ }
19
+
20
+ export class HTMLAriaRoleMustBeValidRule implements Rule {
21
+ name = "html-aria-role-must-be-valid"
22
+
23
+ check(node: Node): LintOffense[] {
24
+ const visitor = new AriaRoleMustBeValid(this.name)
25
+
26
+ visitor.visit(node)
27
+
28
+ return visitor.offenses
29
+ }
30
+ }
@@ -0,0 +1,39 @@
1
+ import { AttributeVisitorMixin } from "./rule-utils"
2
+
3
+ import type { Node } from "@herb-tools/core"
4
+ import type { LintOffense, Rule } from "../types"
5
+
6
+ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
7
+ private documentIds: Set<string> = new Set<string>()
8
+
9
+ protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: Node): void {
10
+ if (attributeName.toLowerCase() !== "id") return
11
+ if (!attributeValue) return
12
+
13
+ const id = attributeValue.trim()
14
+
15
+ if (this.documentIds.has(id)) {
16
+ this.addOffense(
17
+ `Duplicate ID \`${id}\` found. IDs must be unique within a document.`,
18
+ attributeNode.location,
19
+ "error"
20
+ )
21
+
22
+ return
23
+ }
24
+
25
+ this.documentIds.add(id)
26
+ }
27
+ }
28
+
29
+ export class HTMLNoDuplicateIdsRule implements Rule {
30
+ name = "html-no-duplicate-ids"
31
+
32
+ check(node: Node): LintOffense[] {
33
+ const visitor = new NoDuplicateIdsVisitor(this.name)
34
+
35
+ visitor.visit(node)
36
+
37
+ return visitor.offenses
38
+ }
39
+ }
@@ -1,17 +1,29 @@
1
1
  import { BaseRuleVisitor } from "./rule-utils.js"
2
2
 
3
3
  import type { Rule, LintOffense } from "../types.js"
4
- import type { HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
4
+ import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
5
5
 
6
6
  class TagNameLowercaseVisitor extends BaseRuleVisitor {
7
- visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
8
- this.checkTagName(node)
9
- this.visitChildNodes(node)
10
- }
7
+ visitHTMLElementNode(node: HTMLElementNode): void {
8
+ const tagName = node.tag_name?.value
9
+
10
+ if (node.open_tag) {
11
+ this.checkTagName(node.open_tag as HTMLOpenTagNode)
12
+ }
13
+
14
+ if (tagName && ["svg"].includes(tagName.toLowerCase())) {
15
+ if (node.close_tag) {
16
+ this.checkTagName(node.close_tag as HTMLCloseTagNode)
17
+ }
18
+
19
+ return
20
+ }
11
21
 
12
- visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
13
- this.checkTagName(node)
14
22
  this.visitChildNodes(node)
23
+
24
+ if (node.close_tag) {
25
+ this.checkTagName(node.close_tag as HTMLCloseTagNode)
26
+ }
15
27
  }
16
28
 
17
29
  visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
@@ -21,9 +33,12 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
21
33
 
22
34
  private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
23
35
  const tagName = node.tag_name?.value
36
+
24
37
  if (!tagName) return
25
38
 
26
- if (tagName !== tagName.toLowerCase()) {
39
+ const lowercaseTagName = tagName.toLowerCase()
40
+
41
+ if (tagName !== lowercaseTagName) {
27
42
  let type: string = node.type
28
43
 
29
44
  if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
@@ -31,7 +46,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
31
46
  if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"
32
47
 
33
48
  this.addOffense(
34
- `${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`,
49
+ `${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`,
35
50
  node.tag_name!.location,
36
51
  "error"
37
52
  )
@@ -1,12 +1,16 @@
1
1
  export * from "./erb-no-empty-tags.js"
2
2
  export * from "./erb-no-output-control-flow.js"
3
3
  export * from "./html-anchor-require-href.js"
4
+ export * from "./html-aria-role-heading-requires-level.js"
5
+ export * from "./html-aria-role-must-be-valid.js"
4
6
  export * from "./html-attribute-double-quotes.js"
5
7
  export * from "./html-attribute-values-require-quotes.js"
6
8
  export * from "./html-boolean-attributes-no-value.js"
7
9
  export * from "./html-img-require-alt.js"
8
10
  export * from "./html-no-block-inside-inline.js"
9
11
  export * from "./html-no-duplicate-attributes.js"
12
+ export * from "./html-no-duplicate-ids.js"
10
13
  export * from "./html-no-empty-headings.js"
11
14
  export * from "./html-no-nested-links.js"
12
15
  export * from "./html-tag-name-lowercase.js"
16
+ export * from "./svg-tag-name-capitalization.js"