@herb-tools/linter 0.4.3 → 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/README.md +216 -19
- package/dist/herb-lint.js +5559 -1860
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +722 -187
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +714 -189
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/argument-parser.js +28 -22
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +19 -13
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +9 -9
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +50 -0
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +2 -0
- package/dist/src/cli/formatters/index.js.map +1 -1
- package/dist/src/cli/formatters/json-formatter.js +58 -0
- package/dist/src/cli/formatters/json-formatter.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +15 -15
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
- package/dist/src/cli/output-manager.js +120 -0
- package/dist/src/cli/output-manager.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +22 -22
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +41 -26
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +22 -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-empty-tags.js +2 -2
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
- package/dist/src/rules/erb-no-output-control-flow.js +2 -2
- package/dist/src/rules/erb-no-output-control-flow.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 +2 -6
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +2 -2
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
- package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -1
- package/dist/src/rules/html-anchor-require-href.js +2 -2
- package/dist/src/rules/html-anchor-require-href.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +13 -12
- 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 +28 -6
- 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 +9 -15
- 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 +5 -5
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +16 -6
- 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 +21 -10
- 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 +11 -4
- 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 +2 -6
- 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-block-inside-inline.js +4 -4
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-attributes.js +24 -27
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +4 -4
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +2 -23
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-nested-links.js +2 -2
- package/dist/src/rules/html-no-nested-links.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 +37 -25
- 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/parser-no-errors.js +18 -0
- package/dist/src/rules/parser-no-errors.js.map +1 -0
- 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 +2 -10
- 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/argument-parser.d.ts +2 -1
- package/dist/types/cli/file-processor.d.ts +6 -5
- package/dist/types/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/cli/formatters/index.d.ts +2 -0
- package/dist/types/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/cli/index.d.ts +4 -0
- package/dist/types/cli/output-manager.d.ts +31 -0
- package/dist/types/cli/summary-reporter.d.ts +3 -3
- package/dist/types/cli.d.ts +3 -1
- package/dist/types/rules/erb-no-empty-tags.d.ts +2 -2
- package/dist/types/rules/erb-no-output-control-flow.d.ts +2 -2
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +2 -2
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
- package/dist/types/rules/html-anchor-require-href.d.ts +2 -2
- package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/rules/html-aria-level-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +2 -2
- package/dist/types/rules/html-aria-role-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-attribute-double-quotes.d.ts +2 -2
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +2 -2
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +2 -2
- package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/rules/html-img-require-alt.d.ts +2 -2
- 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-block-inside-inline.d.ts +2 -2
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +2 -2
- package/dist/types/rules/html-no-duplicate-ids.d.ts +2 -2
- package/dist/types/rules/html-no-empty-headings.d.ts +2 -2
- package/dist/types/rules/html-no-nested-links.d.ts +2 -2
- 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 +3 -2
- package/dist/types/rules/index.d.ts +10 -0
- package/dist/types/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/rules/rule-utils.d.ts +107 -13
- package/dist/types/rules/svg-tag-name-capitalization.d.ts +2 -2
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -5
- package/dist/types/src/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/src/cli/formatters/index.d.ts +2 -0
- package/dist/types/src/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/src/cli/output-manager.d.ts +31 -0
- package/dist/types/src/cli/summary-reporter.d.ts +3 -3
- package/dist/types/src/cli.d.ts +3 -1
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +2 -2
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +2 -2
- package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +2 -2
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
- package/dist/types/src/rules/html-anchor-require-href.d.ts +2 -2
- package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +2 -2
- package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +2 -2
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +2 -2
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +2 -2
- package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/src/rules/html-img-require-alt.d.ts +2 -2
- 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-block-inside-inline.d.ts +2 -2
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +2 -2
- package/dist/types/src/rules/html-no-duplicate-ids.d.ts +2 -2
- package/dist/types/src/rules/html-no-empty-headings.d.ts +2 -2
- package/dist/types/src/rules/html-no-nested-links.d.ts +2 -2
- 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 +3 -2
- package/dist/types/src/rules/index.d.ts +10 -0
- package/dist/types/src/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/src/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +2 -2
- package/dist/types/src/types.d.ts +27 -3
- package/dist/types/types.d.ts +27 -3
- package/docs/rules/README.md +13 -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/docs/rules/parser-no-errors.md +84 -0
- package/package.json +4 -4
- package/src/cli/argument-parser.ts +33 -24
- package/src/cli/file-processor.ts +25 -17
- package/src/cli/formatters/base-formatter.ts +2 -2
- package/src/cli/formatters/detailed-formatter.ts +9 -9
- package/src/cli/formatters/github-actions-formatter.ts +70 -0
- package/src/cli/formatters/index.ts +2 -0
- package/src/cli/formatters/json-formatter.ts +107 -0
- package/src/cli/formatters/simple-formatter.ts +15 -15
- package/src/cli/output-manager.ts +143 -0
- package/src/cli/summary-reporter.ts +24 -24
- package/src/cli.ts +48 -31
- package/src/default-rules.ts +22 -0
- package/src/linter.ts +30 -4
- package/src/rules/erb-no-empty-tags.ts +3 -3
- package/src/rules/erb-no-output-control-flow.ts +3 -3
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +4 -9
- package/src/rules/erb-require-whitespace-inside-tags.ts +3 -3
- package/src/rules/erb-requires-trailing-newline.ts +2 -0
- package/src/rules/html-anchor-require-href.ts +3 -3
- package/src/rules/html-aria-attribute-must-be-valid.ts +29 -33
- package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
- package/src/rules/html-aria-level-must-be-valid.ts +40 -7
- package/src/rules/html-aria-role-heading-requires-level.ts +18 -30
- package/src/rules/html-aria-role-must-be-valid.ts +7 -7
- package/src/rules/html-attribute-double-quotes.ts +23 -8
- package/src/rules/html-attribute-equals-spacing.ts +41 -0
- package/src/rules/html-attribute-values-require-quotes.ts +32 -12
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
- package/src/rules/html-boolean-attributes-no-value.ts +19 -6
- package/src/rules/html-iframe-has-title.ts +62 -0
- package/src/rules/html-img-require-alt.ts +4 -9
- 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-block-inside-inline.ts +5 -5
- package/src/rules/html-no-duplicate-attributes.ts +30 -30
- package/src/rules/html-no-duplicate-ids.ts +6 -5
- package/src/rules/html-no-empty-headings.ts +4 -33
- package/src/rules/html-no-nested-links.ts +3 -3
- 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 +44 -31
- package/src/rules/index.ts +10 -0
- package/src/rules/parser-no-errors.ts +25 -0
- package/src/rules/rule-utils.ts +260 -39
- package/src/rules/svg-tag-name-capitalization.ts +4 -11
- package/src/types.ts +30 -3
package/src/rules/rule-utils.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Visitor,
|
|
3
3
|
Position,
|
|
4
|
-
Location
|
|
4
|
+
Location,
|
|
5
|
+
getStaticAttributeName,
|
|
6
|
+
hasDynamicAttributeName as hasNodeDynamicAttributeName,
|
|
7
|
+
getCombinedAttributeName,
|
|
8
|
+
hasERBOutput,
|
|
9
|
+
getStaticContentFromNodes,
|
|
10
|
+
hasStaticContent,
|
|
11
|
+
isEffectivelyStatic,
|
|
12
|
+
getValidatableStaticContent
|
|
5
13
|
} from "@herb-tools/core"
|
|
6
14
|
|
|
7
15
|
import type {
|
|
@@ -10,14 +18,16 @@ import type {
|
|
|
10
18
|
HTMLAttributeNode,
|
|
11
19
|
HTMLAttributeValueNode,
|
|
12
20
|
HTMLOpenTagNode,
|
|
13
|
-
HTMLSelfCloseTagNode,
|
|
14
21
|
LiteralNode,
|
|
15
22
|
LexResult,
|
|
16
|
-
Token
|
|
23
|
+
Token,
|
|
24
|
+
Node
|
|
17
25
|
} from "@herb-tools/core"
|
|
18
|
-
|
|
26
|
+
|
|
19
27
|
import { DEFAULT_LINT_CONTEXT } from "../types.js"
|
|
20
28
|
|
|
29
|
+
import type { LintOffense, LintSeverity, LintContext } from "../types.js"
|
|
30
|
+
|
|
21
31
|
/**
|
|
22
32
|
* Base visitor class that provides common functionality for rule visitors
|
|
23
33
|
*/
|
|
@@ -56,34 +66,126 @@ export abstract class BaseRuleVisitor extends Visitor {
|
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/**
|
|
59
|
-
* Gets attributes from
|
|
69
|
+
* Gets attributes from an HTMLOpenTagNode
|
|
60
70
|
*/
|
|
61
|
-
export function getAttributes(node: HTMLOpenTagNode
|
|
62
|
-
return node.type === "
|
|
63
|
-
? (node as HTMLSelfCloseTagNode).attributes
|
|
64
|
-
: (node as HTMLOpenTagNode).children
|
|
71
|
+
export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] {
|
|
72
|
+
return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE") as HTMLAttributeNode[]
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
76
|
* Gets the tag name from an HTML tag node (lowercased)
|
|
69
77
|
*/
|
|
70
|
-
export function getTagName(node: HTMLOpenTagNode
|
|
78
|
+
export function getTagName(node: HTMLOpenTagNode): string | null {
|
|
71
79
|
return node.tag_name?.value.toLowerCase() || null
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/**
|
|
75
83
|
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
84
|
+
* Returns null if the attribute name contains dynamic content (ERB)
|
|
76
85
|
*/
|
|
77
86
|
export function getAttributeName(attributeNode: HTMLAttributeNode): string | null {
|
|
78
87
|
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
79
88
|
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
89
|
+
const staticName = getStaticAttributeName(nameNode)
|
|
80
90
|
|
|
81
|
-
return
|
|
91
|
+
return staticName ? staticName.toLowerCase() : null
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
return null
|
|
85
95
|
}
|
|
86
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Checks if an attribute has a dynamic (ERB-containing) name
|
|
99
|
+
*/
|
|
100
|
+
export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean {
|
|
101
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
102
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
103
|
+
return hasNodeDynamicAttributeName(nameNode)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Gets the combined string representation of an attribute name (for debugging)
|
|
111
|
+
* This includes both static content and ERB syntax
|
|
112
|
+
*/
|
|
113
|
+
export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
|
|
114
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
115
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
116
|
+
|
|
117
|
+
return getCombinedAttributeName(nameNode)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return ""
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checks if an attribute value contains only static content (no ERB)
|
|
125
|
+
*/
|
|
126
|
+
export function hasStaticAttributeValue(attributeNode: HTMLAttributeNode): boolean {
|
|
127
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode | null
|
|
128
|
+
|
|
129
|
+
if (!valueNode?.children) return false
|
|
130
|
+
|
|
131
|
+
return valueNode.children.every(child => child.type === "AST_LITERAL_NODE")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks if an attribute value contains dynamic content (ERB)
|
|
136
|
+
*/
|
|
137
|
+
export function hasDynamicAttributeValue(attributeNode: HTMLAttributeNode): boolean {
|
|
138
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode | null
|
|
139
|
+
|
|
140
|
+
if (!valueNode?.children) return false
|
|
141
|
+
|
|
142
|
+
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Gets the static string value of an attribute (returns null if it contains ERB)
|
|
147
|
+
*/
|
|
148
|
+
export function getStaticAttributeValue(attributeNode: HTMLAttributeNode): string | null {
|
|
149
|
+
if (!hasStaticAttributeValue(attributeNode)) return null
|
|
150
|
+
|
|
151
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
152
|
+
|
|
153
|
+
const result = valueNode.children
|
|
154
|
+
?.filter(child => child.type === "AST_LITERAL_NODE")
|
|
155
|
+
.map(child => (child as LiteralNode).content)
|
|
156
|
+
.join("") || ""
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gets the value nodes array for dynamic inspection
|
|
163
|
+
*/
|
|
164
|
+
export function getAttributeValueNodes(attributeNode: HTMLAttributeNode): Node[] {
|
|
165
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode | null
|
|
166
|
+
|
|
167
|
+
return valueNode?.children || []
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks if an attribute value contains any static content (for validation purposes)
|
|
172
|
+
*/
|
|
173
|
+
export function hasStaticAttributeValueContent(attributeNode: HTMLAttributeNode): boolean {
|
|
174
|
+
const valueNodes = getAttributeValueNodes(attributeNode)
|
|
175
|
+
|
|
176
|
+
return hasStaticContent(valueNodes)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Gets the static content of an attribute value (all literal parts combined)
|
|
181
|
+
* Returns the concatenated literal content, or null if no literal nodes exist
|
|
182
|
+
*/
|
|
183
|
+
export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode): string | null {
|
|
184
|
+
const valueNodes = getAttributeValueNodes(attributeNode)
|
|
185
|
+
|
|
186
|
+
return getStaticContentFromNodes(valueNodes)
|
|
187
|
+
}
|
|
188
|
+
|
|
87
189
|
/**
|
|
88
190
|
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
89
191
|
*/
|
|
@@ -130,9 +232,20 @@ export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
|
|
|
130
232
|
/**
|
|
131
233
|
* Gets the quote type used for an attribute value
|
|
132
234
|
*/
|
|
133
|
-
export function getAttributeValueQuoteType(
|
|
134
|
-
|
|
135
|
-
|
|
235
|
+
export function getAttributeValueQuoteType(nodeOrAttribute: HTMLAttributeNode | HTMLAttributeValueNode): "single" | "double" | "none" | null {
|
|
236
|
+
let valueNode: HTMLAttributeValueNode | undefined
|
|
237
|
+
|
|
238
|
+
if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
239
|
+
const attributeNode = nodeOrAttribute as HTMLAttributeNode
|
|
240
|
+
|
|
241
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
242
|
+
valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
243
|
+
}
|
|
244
|
+
} else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
245
|
+
valueNode = nodeOrAttribute as HTMLAttributeValueNode
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (valueNode) {
|
|
136
249
|
if (valueNode.quoted && valueNode.open_quote) {
|
|
137
250
|
return valueNode.open_quote.value === '"' ? "double" : "single"
|
|
138
251
|
}
|
|
@@ -146,25 +259,35 @@ export function getAttributeValueQuoteType(attributeNode: HTMLAttributeNode): "s
|
|
|
146
259
|
/**
|
|
147
260
|
* Finds an attribute by name in a list of attributes
|
|
148
261
|
*/
|
|
149
|
-
export function findAttributeByName(attributes:
|
|
262
|
+
export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
|
|
150
263
|
for (const child of attributes) {
|
|
151
264
|
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
152
265
|
const attributeNode = child as HTMLAttributeNode
|
|
153
266
|
const name = getAttributeName(attributeNode)
|
|
267
|
+
|
|
154
268
|
if (name === attributeName.toLowerCase()) {
|
|
155
269
|
return attributeNode
|
|
156
270
|
}
|
|
157
271
|
}
|
|
158
272
|
}
|
|
273
|
+
|
|
159
274
|
return null
|
|
160
275
|
}
|
|
161
276
|
|
|
162
277
|
/**
|
|
163
278
|
* Checks if a tag has a specific attribute
|
|
164
279
|
*/
|
|
165
|
-
export function hasAttribute(node: HTMLOpenTagNode
|
|
280
|
+
export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean {
|
|
281
|
+
return getAttribute(node, attributeName) !== null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Checks if a tag has a specific attribute
|
|
286
|
+
*/
|
|
287
|
+
export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
|
|
166
288
|
const attributes = getAttributes(node)
|
|
167
|
-
|
|
289
|
+
|
|
290
|
+
return findAttributeByName(attributes, attributeName)
|
|
168
291
|
}
|
|
169
292
|
|
|
170
293
|
/**
|
|
@@ -184,6 +307,11 @@ export const HTML_BLOCK_ELEMENTS = new Set([
|
|
|
184
307
|
"ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
|
|
185
308
|
])
|
|
186
309
|
|
|
310
|
+
export const HTML_VOID_ELEMENTS = new Set([
|
|
311
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
|
|
312
|
+
"param", "source", "track", "wbr",
|
|
313
|
+
])
|
|
314
|
+
|
|
187
315
|
export const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
188
316
|
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
|
|
189
317
|
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
|
|
@@ -254,6 +382,41 @@ export const VALID_ARIA_ROLES = new Set([
|
|
|
254
382
|
"log", "marquee"
|
|
255
383
|
]);
|
|
256
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Parameter types for AttributeVisitorMixin methods
|
|
387
|
+
*/
|
|
388
|
+
export interface StaticAttributeStaticValueParams {
|
|
389
|
+
attributeName: string
|
|
390
|
+
attributeValue: string
|
|
391
|
+
attributeNode: HTMLAttributeNode
|
|
392
|
+
parentNode: HTMLOpenTagNode
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface StaticAttributeDynamicValueParams {
|
|
396
|
+
attributeName: string
|
|
397
|
+
valueNodes: Node[]
|
|
398
|
+
attributeNode: HTMLAttributeNode
|
|
399
|
+
parentNode: HTMLOpenTagNode
|
|
400
|
+
combinedValue?: string | null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface DynamicAttributeStaticValueParams {
|
|
404
|
+
nameNodes: Node[]
|
|
405
|
+
attributeValue: string
|
|
406
|
+
attributeNode: HTMLAttributeNode
|
|
407
|
+
parentNode: HTMLOpenTagNode
|
|
408
|
+
combinedName?: string
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface DynamicAttributeDynamicValueParams {
|
|
412
|
+
nameNodes: Node[]
|
|
413
|
+
valueNodes: Node[]
|
|
414
|
+
attributeNode: HTMLAttributeNode
|
|
415
|
+
parentNode: HTMLOpenTagNode
|
|
416
|
+
combinedName?: string
|
|
417
|
+
combinedValue?: string | null
|
|
418
|
+
}
|
|
419
|
+
|
|
257
420
|
export const ARIA_ATTRIBUTES = new Set([
|
|
258
421
|
'aria-activedescendant',
|
|
259
422
|
'aria-atomic',
|
|
@@ -335,6 +498,13 @@ export function isBlockElement(tagName: string): boolean {
|
|
|
335
498
|
return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase())
|
|
336
499
|
}
|
|
337
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Checks if an element is a void element
|
|
503
|
+
*/
|
|
504
|
+
export function isVoidElement(tagName: string): boolean {
|
|
505
|
+
return HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
|
|
506
|
+
}
|
|
507
|
+
|
|
338
508
|
/**
|
|
339
509
|
* Checks if an attribute is a boolean attribute
|
|
340
510
|
*/
|
|
@@ -343,9 +513,14 @@ export function isBooleanAttribute(attributeName: string): boolean {
|
|
|
343
513
|
}
|
|
344
514
|
|
|
345
515
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
516
|
+
* Attribute visitor that provides granular processing based on both
|
|
517
|
+
* attribute name type (static/dynamic) and value type (static/dynamic)
|
|
518
|
+
*
|
|
519
|
+
* This gives you 4 distinct methods to override:
|
|
520
|
+
* - checkStaticAttributeStaticValue() - name="class" value="foo"
|
|
521
|
+
* - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
|
|
522
|
+
* - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
|
|
523
|
+
* - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
|
|
349
524
|
*/
|
|
350
525
|
export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
351
526
|
constructor(ruleName: string, context?: Partial<LintContext>) {
|
|
@@ -357,28 +532,74 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
357
532
|
super.visitHTMLOpenTagNode(node)
|
|
358
533
|
}
|
|
359
534
|
|
|
360
|
-
|
|
361
|
-
this.checkAttributesOnNode(node)
|
|
362
|
-
super.visitHTMLSelfCloseTagNode(node)
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
private checkAttributesOnNode(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
535
|
+
private checkAttributesOnNode(node: HTMLOpenTagNode): void {
|
|
366
536
|
forEachAttribute(node, (attributeNode) => {
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
537
|
+
const staticAttributeName = getAttributeName(attributeNode)
|
|
538
|
+
const isDynamicName = hasDynamicAttributeName(attributeNode)
|
|
539
|
+
const staticAttributeValue = getStaticAttributeValue(attributeNode)
|
|
540
|
+
const valueNodes = getAttributeValueNodes(attributeNode)
|
|
541
|
+
const hasOutputERB = hasERBOutput(valueNodes)
|
|
542
|
+
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)
|
|
543
|
+
|
|
544
|
+
if (staticAttributeName && staticAttributeValue !== null) {
|
|
545
|
+
this.checkStaticAttributeStaticValue({
|
|
546
|
+
attributeName: staticAttributeName,
|
|
547
|
+
attributeValue: staticAttributeValue,
|
|
548
|
+
attributeNode,
|
|
549
|
+
parentNode: node
|
|
550
|
+
})
|
|
551
|
+
} else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
|
|
552
|
+
const validatableContent = getValidatableStaticContent(valueNodes) || ""
|
|
553
|
+
|
|
554
|
+
this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node })
|
|
555
|
+
} else if (staticAttributeName && hasOutputERB) {
|
|
556
|
+
const combinedValue = getAttributeValue(attributeNode)
|
|
557
|
+
|
|
558
|
+
this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue })
|
|
559
|
+
} else if (isDynamicName && staticAttributeValue !== null) {
|
|
560
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
561
|
+
const nameNodes = nameNode.children || []
|
|
562
|
+
const combinedName = getCombinedAttributeNameString(attributeNode)
|
|
563
|
+
|
|
564
|
+
this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName })
|
|
565
|
+
} else if (isDynamicName) {
|
|
566
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
567
|
+
const nameNodes = nameNode.children || []
|
|
568
|
+
const combinedName = getCombinedAttributeNameString(attributeNode)
|
|
569
|
+
const combinedValue = getAttributeValue(attributeNode)
|
|
570
|
+
|
|
571
|
+
this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue })
|
|
372
572
|
}
|
|
373
573
|
})
|
|
374
574
|
}
|
|
375
575
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Static attribute name with static value: class="container"
|
|
578
|
+
*/
|
|
579
|
+
protected checkStaticAttributeStaticValue(params: StaticAttributeStaticValueParams): void {
|
|
580
|
+
// Default implementation does nothing
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Static attribute name with dynamic value: class="<%= css_class %>"
|
|
585
|
+
*/
|
|
586
|
+
protected checkStaticAttributeDynamicValue(params: StaticAttributeDynamicValueParams): void {
|
|
587
|
+
// Default implementation does nothing
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Dynamic attribute name with static value: data-<%= key %>="foo"
|
|
592
|
+
*/
|
|
593
|
+
protected checkDynamicAttributeStaticValue(params: DynamicAttributeStaticValueParams): void {
|
|
594
|
+
// Default implementation does nothing
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
|
|
599
|
+
*/
|
|
600
|
+
protected checkDynamicAttributeDynamicValue(params: DynamicAttributeDynamicValueParams): void {
|
|
601
|
+
// Default implementation does nothing
|
|
602
|
+
}
|
|
382
603
|
}
|
|
383
604
|
|
|
384
605
|
/**
|
|
@@ -398,7 +619,7 @@ export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolea
|
|
|
398
619
|
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
399
620
|
*/
|
|
400
621
|
export function forEachAttribute(
|
|
401
|
-
node: HTMLOpenTagNode
|
|
622
|
+
node: HTMLOpenTagNode,
|
|
402
623
|
callback: (attributeNode: HTMLAttributeNode) => void
|
|
403
624
|
): void {
|
|
404
625
|
const attributes = getAttributes(node)
|
|
@@ -490,7 +711,7 @@ export abstract class BaseSourceRuleVisitor {
|
|
|
490
711
|
*/
|
|
491
712
|
protected createOffense(message: string, location: Location, severity: LintSeverity = "error"): LintOffense {
|
|
492
713
|
return {
|
|
493
|
-
rule: this.ruleName
|
|
714
|
+
rule: this.ruleName,
|
|
494
715
|
code: this.ruleName,
|
|
495
716
|
source: "Herb Linter",
|
|
496
717
|
message,
|
|
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE }
|
|
|
2
2
|
|
|
3
3
|
import { ParserRule } from "../types.js"
|
|
4
4
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode,
|
|
5
|
+
import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, ParseResult } from "@herb-tools/core"
|
|
6
6
|
|
|
7
7
|
class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
8
8
|
private insideSVG = false
|
|
@@ -30,14 +30,8 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
30
30
|
this.visitChildNodes(node)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
34
|
-
if (this.insideSVG) {
|
|
35
|
-
this.checkTagName(node)
|
|
36
|
-
}
|
|
37
|
-
this.visitChildNodes(node)
|
|
38
|
-
}
|
|
39
33
|
|
|
40
|
-
private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode
|
|
34
|
+
private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode): void {
|
|
41
35
|
const tagName = node.tag_name?.value
|
|
42
36
|
|
|
43
37
|
if (!tagName) return
|
|
@@ -52,7 +46,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
52
46
|
|
|
53
47
|
if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
|
|
54
48
|
if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
|
|
55
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"
|
|
56
49
|
|
|
57
50
|
this.addOffense(
|
|
58
51
|
`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`,
|
|
@@ -66,9 +59,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
66
59
|
export class SVGTagNameCapitalizationRule extends ParserRule {
|
|
67
60
|
name = "svg-tag-name-capitalization"
|
|
68
61
|
|
|
69
|
-
check(
|
|
62
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
70
63
|
const visitor = new SVGTagNameCapitalizationVisitor(this.name, context)
|
|
71
|
-
visitor.visit(
|
|
64
|
+
visitor.visit(result.value)
|
|
72
65
|
return visitor.offenses
|
|
73
66
|
}
|
|
74
67
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Diagnostic, LexResult, ParseResult } from "@herb-tools/core"
|
|
2
2
|
import type { defaultRules } from "./default-rules.js"
|
|
3
3
|
|
|
4
|
-
export type LintSeverity = "error" | "warning"
|
|
4
|
+
export type LintSeverity = "error" | "warning" | "info" | "hint"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Automatically inferred union type of all available linter rule names.
|
|
@@ -23,13 +23,31 @@ export interface LintResult {
|
|
|
23
23
|
export abstract class ParserRule {
|
|
24
24
|
static type = "parser" as const
|
|
25
25
|
abstract name: string
|
|
26
|
-
abstract check(
|
|
26
|
+
abstract check(result: ParseResult, context?: Partial<LintContext>): LintOffense[]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Optional method to determine if this rule should run.
|
|
30
|
+
* If not implemented, rule is always enabled.
|
|
31
|
+
* @param result - The parse result to analyze
|
|
32
|
+
* @param context - Optional context for linting
|
|
33
|
+
* @returns true if rule should run, false to skip
|
|
34
|
+
*/
|
|
35
|
+
isEnabled?(result: ParseResult, context?: Partial<LintContext>): boolean
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export abstract class LexerRule {
|
|
30
39
|
static type = "lexer" as const
|
|
31
40
|
abstract name: string
|
|
32
41
|
abstract check(lexResult: LexResult, context?: Partial<LintContext>): LintOffense[]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Optional method to determine if this rule should run.
|
|
45
|
+
* If not implemented, rule is always enabled.
|
|
46
|
+
* @param lexResult - The lex result to analyze
|
|
47
|
+
* @param context - Optional context for linting
|
|
48
|
+
* @returns true if rule should run, false to skip
|
|
49
|
+
*/
|
|
50
|
+
isEnabled?(lexResult: LexResult, context?: Partial<LintContext>): boolean
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export interface LexerRuleConstructor {
|
|
@@ -56,6 +74,15 @@ export abstract class SourceRule {
|
|
|
56
74
|
static type = "source" as const
|
|
57
75
|
abstract name: string
|
|
58
76
|
abstract check(source: string, context?: Partial<LintContext>): LintOffense[]
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Optional method to determine if this rule should run.
|
|
80
|
+
* If not implemented, rule is always enabled.
|
|
81
|
+
* @param source - The source code to analyze
|
|
82
|
+
* @param context - Optional context for linting
|
|
83
|
+
* @returns true if rule should run, false to skip
|
|
84
|
+
*/
|
|
85
|
+
isEnabled?(source: string, context?: Partial<LintContext>): boolean
|
|
59
86
|
}
|
|
60
87
|
|
|
61
88
|
export interface SourceRuleConstructor {
|