@herb-tools/linter 0.5.0 → 0.6.1
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 +6627 -1937
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1574 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1566 -212
- package/dist/index.js.map +1 -1
- package/dist/package.json +5 -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 +50 -64
- 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 +134 -9
- 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 +29 -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 +245 -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 +146 -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 +146 -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 +5 -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 +53 -76
- 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 +189 -14
- 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 +41 -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 +357 -39
- package/src/rules/svg-tag-name-capitalization.ts +2 -9
- package/src/types.ts +27 -0
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js"
|
|
1
2
|
import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { ERBToRubyStringPrinter } from "@herb-tools/printer"
|
|
5
|
+
import { filterNodes, ERBContentNode, LiteralNode, isNode } from "@herb-tools/core"
|
|
6
|
+
|
|
4
7
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { HTMLOpenTagNode,
|
|
8
|
+
import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
|
|
6
9
|
|
|
7
10
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
8
11
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
@@ -10,106 +13,80 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
10
13
|
super.visitHTMLOpenTagNode(node)
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
super.visitHTMLSelfCloseTagNode(node)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
19
|
-
const tagName = getTagName(node)
|
|
16
|
+
private checkImgTag(openTag: HTMLOpenTagNode): void {
|
|
17
|
+
const tagName = getTagName(openTag)
|
|
20
18
|
|
|
21
|
-
if (tagName !== "img")
|
|
22
|
-
return
|
|
23
|
-
}
|
|
19
|
+
if (tagName !== "img") return
|
|
24
20
|
|
|
25
|
-
const attributes = getAttributes(
|
|
21
|
+
const attributes = getAttributes(openTag)
|
|
26
22
|
const srcAttribute = findAttributeByName(attributes, "src")
|
|
27
23
|
|
|
28
|
-
if (!srcAttribute)
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (!srcAttribute.value) {
|
|
33
|
-
return
|
|
34
|
-
}
|
|
24
|
+
if (!srcAttribute) return
|
|
25
|
+
if (!srcAttribute.value) return
|
|
35
26
|
|
|
36
|
-
const
|
|
37
|
-
const hasERBContent = this.containsERBContent(
|
|
27
|
+
const node = srcAttribute.value
|
|
28
|
+
const hasERBContent = this.containsERBContent(node)
|
|
38
29
|
|
|
39
30
|
if (hasERBContent) {
|
|
40
|
-
|
|
31
|
+
if (this.isDataUri(node)) return
|
|
41
32
|
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
if (this.shouldFlagAsImageTagCandidate(node)) {
|
|
34
|
+
const suggestedExpression = this.buildSuggestedExpression(node)
|
|
35
|
+
|
|
36
|
+
this.addOffense(
|
|
37
|
+
`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
|
|
38
|
+
srcAttribute.location,
|
|
39
|
+
"warning"
|
|
40
|
+
)
|
|
41
|
+
}
|
|
47
42
|
}
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
private containsERBContent(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
|
|
45
|
+
private containsERBContent(node: HTMLAttributeValueNode): boolean {
|
|
46
|
+
return filterNodes(node.children, ERBContentNode).length > 0
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
private
|
|
57
|
-
|
|
49
|
+
private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
|
|
50
|
+
return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
|
|
51
|
+
}
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
private getContentofFirstChild(node: HTMLAttributeValueNode): string {
|
|
54
|
+
if (!node.children || node.children.length === 0) return ""
|
|
61
55
|
|
|
62
|
-
|
|
63
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
64
|
-
hasERB = true
|
|
65
|
-
} else if (child.type === "AST_LITERAL_NODE") {
|
|
66
|
-
const literalNode = child as LiteralNode
|
|
56
|
+
const firstChild = node.children[0]
|
|
67
57
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
}
|
|
58
|
+
if (isNode(firstChild, LiteralNode)) {
|
|
59
|
+
return (firstChild.content || "").trim()
|
|
72
60
|
}
|
|
73
61
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
for (const child of valueNode.children) {
|
|
78
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
79
|
-
const erbNode = child as ERBContentNode
|
|
80
|
-
|
|
81
|
-
result += `#{${(erbNode.content?.value || "").trim()}}`
|
|
82
|
-
} else if (child.type === "AST_LITERAL_NODE") {
|
|
83
|
-
const literalNode = child as LiteralNode
|
|
84
|
-
|
|
85
|
-
result += literalNode.content || ""
|
|
86
|
-
}
|
|
87
|
-
}
|
|
62
|
+
return ""
|
|
63
|
+
}
|
|
88
64
|
|
|
89
|
-
|
|
65
|
+
private isDataUri(node: HTMLAttributeValueNode): boolean {
|
|
66
|
+
return this.getContentofFirstChild(node).startsWith("data:")
|
|
67
|
+
}
|
|
90
68
|
|
|
91
|
-
|
|
92
|
-
|
|
69
|
+
private isFullUrl(node: HTMLAttributeValueNode): boolean {
|
|
70
|
+
const content = this.getContentofFirstChild(node)
|
|
93
71
|
|
|
94
|
-
|
|
95
|
-
|
|
72
|
+
return content.startsWith("http://") || content.startsWith("https://")
|
|
73
|
+
}
|
|
96
74
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
let result = '"'
|
|
75
|
+
private shouldFlagAsImageTagCandidate(node: HTMLAttributeValueNode): boolean {
|
|
76
|
+
if (this.isOnlyERBContent(node)) return true
|
|
77
|
+
if (this.isFullUrl(node)) return false
|
|
101
78
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
105
81
|
|
|
106
|
-
|
|
82
|
+
private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
|
|
83
|
+
if (!node.children) return "expression"
|
|
107
84
|
|
|
108
|
-
|
|
109
|
-
}
|
|
85
|
+
try {
|
|
86
|
+
return ERBToRubyStringPrinter.print(node, { ignoreErrors: false })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return "expression"
|
|
110
89
|
}
|
|
111
|
-
|
|
112
|
-
return "expression"
|
|
113
90
|
}
|
|
114
91
|
}
|
|
115
92
|
|
|
@@ -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,
|
|
@@ -1,36 +1,22 @@
|
|
|
1
|
-
import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./rule-utils.js"
|
|
2
|
-
|
|
3
1
|
import { ParserRule } from "../types.js"
|
|
2
|
+
import { AttributeVisitorMixin, getAttributeName, getAttributes, StaticAttributeStaticValueParams } from "./rule-utils.js"
|
|
3
|
+
|
|
4
4
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { ParseResult
|
|
5
|
+
import type { ParseResult } from "@herb-tools/core"
|
|
6
6
|
|
|
7
7
|
class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
8
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }: StaticAttributeStaticValueParams): void {
|
|
9
|
+
if (!(attributeName === "role" && attributeValue === "heading")) return
|
|
10
|
+
|
|
11
|
+
const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level")
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
|
|
17
|
-
): void {
|
|
18
|
-
|
|
19
|
-
if (!(attributeName === "role" && attributeValue === "heading")) {
|
|
20
|
-
return
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const allAttributes = getAttributes(parentNode)
|
|
24
|
-
|
|
25
|
-
// If we have a role="heading", we must check for aria-level
|
|
26
|
-
const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level")
|
|
27
|
-
if (!ariaLevelAttr) {
|
|
28
|
-
this.addOffense(
|
|
29
|
-
`Element with \`role="heading"\` must have an \`aria-level\` attribute.`,
|
|
30
|
-
attributeNode.location,
|
|
31
|
-
"error"
|
|
32
|
-
)
|
|
33
|
-
}
|
|
13
|
+
if (ariaLevelAttributes) return
|
|
14
|
+
|
|
15
|
+
this.addOffense(
|
|
16
|
+
`Element with \`role="heading"\` must have an \`aria-level\` attribute.`,
|
|
17
|
+
attributeNode.location,
|
|
18
|
+
"error"
|
|
19
|
+
)
|
|
34
20
|
}
|
|
35
21
|
}
|
|
36
22
|
|
|
@@ -39,7 +25,9 @@ export class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
|
39
25
|
|
|
40
26
|
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
41
27
|
const visitor = new AriaRoleHeadingRequiresLevel(this.name, context)
|
|
28
|
+
|
|
42
29
|
visitor.visit(result.value)
|
|
30
|
+
|
|
43
31
|
return visitor.offenses
|
|
44
32
|
}
|
|
45
33
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./rule-utils.js"
|
|
2
|
-
|
|
3
1
|
import { ParserRule } from "../types.js"
|
|
2
|
+
import { AttributeVisitorMixin, VALID_ARIA_ROLES, StaticAttributeStaticValueParams } from "./rule-utils.js"
|
|
3
|
+
|
|
4
4
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { ParseResult
|
|
5
|
+
import type { ParseResult } from "@herb-tools/core"
|
|
6
6
|
|
|
7
7
|
class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
8
|
-
|
|
8
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
|
|
9
9
|
if (attributeName !== "role") return
|
|
10
|
-
if (attributeValue
|
|
10
|
+
if (!attributeValue) return
|
|
11
11
|
if (VALID_ARIA_ROLES.has(attributeValue)) return
|
|
12
12
|
|
|
13
13
|
this.addOffense(
|
|
@@ -1,17 +1,30 @@
|
|
|
1
|
-
import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
|
|
2
|
-
|
|
3
1
|
import { ParserRule } from "../types.js"
|
|
2
|
+
import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
|
|
3
|
+
import { filterLiteralNodes } from "@herb-tools/core"
|
|
4
|
+
|
|
4
5
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { ParseResult
|
|
6
|
+
import type { ParseResult } from "@herb-tools/core"
|
|
6
7
|
|
|
7
8
|
class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
8
|
-
protected
|
|
9
|
+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams) {
|
|
9
10
|
if (!hasAttributeValue(attributeNode)) return
|
|
10
11
|
if (getAttributeValueQuoteType(attributeNode) !== "single") return
|
|
11
|
-
if (attributeValue?.includes('"')) return
|
|
12
|
+
if (attributeValue?.includes('"')) return
|
|
12
13
|
|
|
13
14
|
this.addOffense(
|
|
14
|
-
`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="
|
|
15
|
+
`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`,
|
|
16
|
+
attributeNode.value!.location,
|
|
17
|
+
"warning"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
protected checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }: StaticAttributeDynamicValueParams) {
|
|
22
|
+
if (!hasAttributeValue(attributeNode)) return
|
|
23
|
+
if (getAttributeValueQuoteType(attributeNode) !== "single") return
|
|
24
|
+
if (filterLiteralNodes(valueNodes).some(node => node.content?.includes('"'))) return
|
|
25
|
+
|
|
26
|
+
this.addOffense(
|
|
27
|
+
`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`,
|
|
15
28
|
attributeNode.value!.location,
|
|
16
29
|
"warning"
|
|
17
30
|
)
|
|
@@ -23,7 +36,9 @@ export class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
|
23
36
|
|
|
24
37
|
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
25
38
|
const visitor = new AttributeDoubleQuotesVisitor(this.name, context)
|
|
39
|
+
|
|
26
40
|
visitor.visit(result.value)
|
|
41
|
+
|
|
27
42
|
return visitor.offenses
|
|
28
43
|
}
|
|
29
44
|
}
|