@herb-tools/linter 0.4.1 → 0.4.3
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 +16 -4
- package/dist/herb-lint.js +557 -122
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +454 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +448 -69
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/file-processor.js +2 -4
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/default-rules.js +8 -0
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js +37 -6
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-no-empty-tags.js +4 -3
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
- package/dist/src/rules/erb-no-output-control-flow.js +4 -3
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +93 -0
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +22 -5
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
- package/dist/src/rules/erb-requires-trailing-newline.js +22 -0
- package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -0
- package/dist/src/rules/html-anchor-require-href.js +4 -3
- package/dist/src/rules/html-anchor-require-href.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +4 -3
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-level-must-be-valid.js +27 -0
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js +4 -3
- 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 +4 -3
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +4 -3
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
- package/dist/src/rules/html-attribute-values-require-quotes.js +4 -3
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +4 -3
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-img-require-alt.js +4 -3
- package/dist/src/rules/html-img-require-alt.js.map +1 -1
- package/dist/src/rules/html-no-block-inside-inline.js +4 -3
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-attributes.js +4 -3
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +4 -3
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +4 -3
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-nested-links.js +4 -3
- package/dist/src/rules/html-no-nested-links.js.map +1 -1
- package/dist/src/rules/html-tag-name-lowercase.js +21 -11
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
- package/dist/src/rules/index.js +4 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +168 -2
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +58 -0
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -0
- package/dist/src/types.js +15 -1
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/linter.d.ts +20 -5
- package/dist/types/rules/erb-no-empty-tags.d.ts +4 -3
- package/dist/types/rules/erb-no-output-control-flow.d.ts +4 -3
- package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +7 -0
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
- package/dist/types/rules/erb-requires-trailing-newline.d.ts +6 -0
- package/dist/types/rules/html-anchor-require-href.d.ts +4 -3
- package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
- package/dist/types/rules/html-aria-level-must-be-valid.d.ts +7 -0
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +4 -3
- package/dist/types/rules/html-aria-role-must-be-valid.d.ts +4 -3
- package/dist/types/rules/html-attribute-double-quotes.d.ts +4 -3
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +4 -3
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +4 -3
- package/dist/types/rules/html-img-require-alt.d.ts +4 -3
- package/dist/types/rules/html-no-block-inside-inline.d.ts +4 -3
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +4 -3
- package/dist/types/rules/html-no-duplicate-ids.d.ts +4 -3
- package/dist/types/rules/html-no-empty-headings.d.ts +4 -3
- package/dist/types/rules/html-no-nested-links.d.ts +4 -3
- package/dist/types/rules/html-tag-name-lowercase.d.ts +4 -3
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/rule-utils.d.ts +82 -4
- package/dist/types/rules/svg-tag-name-capitalization.d.ts +7 -0
- package/dist/types/src/linter.d.ts +20 -5
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +4 -3
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +4 -3
- package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +7 -0
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
- package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +6 -0
- package/dist/types/src/rules/html-anchor-require-href.d.ts +4 -3
- package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
- package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +7 -0
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +4 -3
- package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +4 -3
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +4 -3
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +4 -3
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +4 -3
- package/dist/types/src/rules/html-img-require-alt.d.ts +4 -3
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +4 -3
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +4 -3
- package/dist/types/src/rules/html-no-duplicate-ids.d.ts +4 -3
- package/dist/types/src/rules/html-no-empty-headings.d.ts +4 -3
- package/dist/types/src/rules/html-no-nested-links.d.ts +4 -3
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +4 -3
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/rule-utils.d.ts +82 -4
- package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +7 -0
- package/dist/types/src/types.d.ts +49 -6
- package/dist/types/types.d.ts +49 -6
- package/docs/rules/README.md +5 -1
- package/docs/rules/erb-prefer-image-tag-helper.md +65 -0
- package/docs/rules/erb-requires-trailing-newline.md +37 -0
- package/docs/rules/html-anchor-require-href.md +1 -1
- package/docs/rules/html-aria-level-must-be-valid.md +37 -0
- package/docs/rules/svg-tag-name-capitalization.md +57 -0
- package/package.json +4 -4
- package/src/cli/file-processor.ts +2 -4
- package/src/default-rules.ts +8 -0
- package/src/linter.ts +42 -8
- package/src/rules/erb-no-empty-tags.ts +5 -4
- package/src/rules/erb-no-output-control-flow.ts +6 -4
- package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
- package/src/rules/erb-require-whitespace-inside-tags.ts +38 -6
- package/src/rules/erb-requires-trailing-newline.ts +27 -0
- package/src/rules/html-anchor-require-href.ts +5 -4
- package/src/rules/html-aria-attribute-must-be-valid.ts +5 -5
- package/src/rules/html-aria-level-must-be-valid.ts +42 -0
- package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
- package/src/rules/html-aria-role-must-be-valid.ts +5 -4
- package/src/rules/html-attribute-double-quotes.ts +5 -4
- package/src/rules/html-attribute-values-require-quotes.ts +5 -4
- package/src/rules/html-boolean-attributes-no-value.ts +5 -4
- package/src/rules/html-img-require-alt.ts +5 -4
- package/src/rules/html-no-block-inside-inline.ts +5 -4
- package/src/rules/html-no-duplicate-attributes.ts +5 -4
- package/src/rules/html-no-duplicate-ids.ts +5 -5
- package/src/rules/html-no-empty-headings.ts +5 -4
- package/src/rules/html-no-nested-links.ts +5 -4
- package/src/rules/html-tag-name-lowercase.ts +29 -13
- package/src/rules/index.ts +4 -0
- package/src/rules/rule-utils.ts +203 -4
- package/src/rules/svg-tag-name-capitalization.ts +74 -0
- package/src/types.ts +60 -6
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Node, Diagnostic } from "@herb-tools/core";
|
|
1
|
+
import { Node, Diagnostic, LexResult } from "@herb-tools/core";
|
|
2
2
|
import type { defaultRules } from "./default-rules.js";
|
|
3
3
|
export type LintSeverity = "error" | "warning";
|
|
4
4
|
/**
|
|
@@ -15,12 +15,55 @@ export interface LintResult {
|
|
|
15
15
|
errors: number;
|
|
16
16
|
warnings: number;
|
|
17
17
|
}
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export declare abstract class ParserRule {
|
|
19
|
+
static type: "parser";
|
|
20
|
+
abstract name: string;
|
|
21
|
+
abstract check(node: Node, context?: Partial<LintContext>): LintOffense[];
|
|
22
|
+
}
|
|
23
|
+
export declare abstract class LexerRule {
|
|
24
|
+
static type: "lexer";
|
|
25
|
+
abstract name: string;
|
|
26
|
+
abstract check(lexResult: LexResult, context?: Partial<LintContext>): LintOffense[];
|
|
27
|
+
}
|
|
28
|
+
export interface LexerRuleConstructor {
|
|
29
|
+
type: "lexer";
|
|
30
|
+
new (): LexerRule;
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
|
-
*
|
|
33
|
+
* Complete lint context with all properties defined.
|
|
34
|
+
* Use Partial<LintContext> when passing context to rules.
|
|
35
|
+
*/
|
|
36
|
+
export interface LintContext {
|
|
37
|
+
fileName: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Default context object with all keys defined but set to undefined
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEFAULT_LINT_CONTEXT: LintContext;
|
|
43
|
+
export declare abstract class SourceRule {
|
|
44
|
+
static type: "source";
|
|
45
|
+
abstract name: string;
|
|
46
|
+
abstract check(source: string, context?: Partial<LintContext>): LintOffense[];
|
|
47
|
+
}
|
|
48
|
+
export interface SourceRuleConstructor {
|
|
49
|
+
type: "source";
|
|
50
|
+
new (): SourceRule;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Type representing a parser/AST rule class constructor.
|
|
24
54
|
* The Linter accepts rule classes rather than instances for better performance and memory usage.
|
|
55
|
+
* Parser rules are the default and don't require static properties.
|
|
56
|
+
*/
|
|
57
|
+
export type ParserRuleClass = (new () => ParserRule) & {
|
|
58
|
+
type?: "parser";
|
|
59
|
+
};
|
|
60
|
+
export type LexerRuleClass = LexerRuleConstructor;
|
|
61
|
+
export type SourceRuleClass = SourceRuleConstructor;
|
|
62
|
+
/**
|
|
63
|
+
* Union type for any rule instance (Parser/AST, Lexer, or Source)
|
|
64
|
+
*/
|
|
65
|
+
export type Rule = ParserRule | LexerRule | SourceRule;
|
|
66
|
+
/**
|
|
67
|
+
* Union type for any rule class (Parser/AST, Lexer, or Source)
|
|
25
68
|
*/
|
|
26
|
-
export type RuleClass =
|
|
69
|
+
export type RuleClass = ParserRuleClass | LexerRuleClass | SourceRuleClass;
|
package/docs/rules/README.md
CHANGED
|
@@ -6,9 +6,12 @@ This page contains documentation for all Herb Linter rules.
|
|
|
6
6
|
|
|
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
|
+
- [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `<img>` with ERB expressions
|
|
9
10
|
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around erb tags
|
|
11
|
+
- [`erb-requires-trailing-newline`](./erb-requires-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character.
|
|
10
12
|
- [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
|
|
11
13
|
- [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
|
|
14
|
+
- [`html-aria-level-must-be-valid`](./html-aria-level-must-be-valid.md) - `aria-level` must be between 1 and 6
|
|
12
15
|
- [`html-aria-role-heading-requires-level`](./html-aria-role-heading-requires-level.md) - Requires `aria-level` when supplying a `role`
|
|
13
16
|
- [`html-aria-role-must-be-valid`](./html-aria-role-must-be-valid.md) - The `role` attribute must have a valid WAI-ARIA Role.
|
|
14
17
|
- [`html-attribute-double-quotes`](./html-attribute-double-quotes.md) - Enforces double quotes for attribute values
|
|
@@ -16,10 +19,11 @@ This page contains documentation for all Herb Linter rules.
|
|
|
16
19
|
- [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes
|
|
17
20
|
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires alt attributes on img tags
|
|
18
21
|
- [`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
|
|
20
22
|
- [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
|
|
23
|
+
- [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
|
|
21
24
|
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
|
|
22
25
|
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
|
|
26
|
+
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
|
|
23
27
|
|
|
24
28
|
## Contributing
|
|
25
29
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Linter Rule: Prefer `image_tag` helper over `<img>` with ERB expressions
|
|
2
|
+
|
|
3
|
+
**Rule:** `erb-prefer-image-tag-helper`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Prefer using Rails' `image_tag` helper over manual `<img>` tags with dynamic ERB expressions like `image_path` or `asset_path`.
|
|
8
|
+
|
|
9
|
+
## Rationale
|
|
10
|
+
|
|
11
|
+
The `image_tag` helper provides several advantages over manual `<img>` tags with dynamic ERB expressions. It properly escapes the `src` value to prevent XSS vulnerabilities and ensures consistent rendering across different contexts. Using `image_tag` also reduces template complexity by eliminating the need for manual string interpolation and makes it easier to add additional attributes like `alt`, `class`, or `data-*` attributes in a clean, readable way. Additionally, it prevents common interpolation issues that can arise when mixing ERB expressions with static text in attribute values.
|
|
12
|
+
|
|
13
|
+
## Examples
|
|
14
|
+
|
|
15
|
+
### ✅ Good
|
|
16
|
+
|
|
17
|
+
```erb
|
|
18
|
+
<!-- Simple image_tag usage -->
|
|
19
|
+
<%= image_tag "logo.png", alt: "Logo" %>
|
|
20
|
+
<%= image_tag "banner.jpg", alt: "Banner", class: "hero-image" %>
|
|
21
|
+
<%= image_tag "icon.svg", alt: "Icon", size: "24x24" %>
|
|
22
|
+
|
|
23
|
+
<!-- Dynamic expressions -->
|
|
24
|
+
<%= image_tag user.avatar.url, alt: "User avatar" %>
|
|
25
|
+
|
|
26
|
+
<!-- Mixed content using string interpolation -->
|
|
27
|
+
<%= image_tag "#{root_url}/banner.jpg", alt: "Banner" %>
|
|
28
|
+
<%= image_tag "#{base_url}#{image_path('icon.png')}", alt: "Icon" %>
|
|
29
|
+
|
|
30
|
+
<!-- Static image paths are fine -->
|
|
31
|
+
<img src="/static/logo.png" alt="Logo">
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 🚫 Bad
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<!-- Single ERB expressions -->
|
|
38
|
+
<img src="<%= image_path("logo.png") %>" alt="Logo">
|
|
39
|
+
|
|
40
|
+
<img src="<%= asset_path("banner.jpg") %>" alt="Banner">
|
|
41
|
+
|
|
42
|
+
<img src="<%= user.avatar.url %>" alt="User avatar">
|
|
43
|
+
|
|
44
|
+
<img src="<%= product.image %>" alt="Product image">
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
<!-- Mixed ERB and text content -->
|
|
48
|
+
<img src="<%= Rails.application.routes.url_helpers.root_url %>/icon.png" alt="Logo">
|
|
49
|
+
|
|
50
|
+
<img src="<%= root_url %>/banner.jpg" alt="Banner">
|
|
51
|
+
|
|
52
|
+
<img src="<%= admin_path %>/icon.png" alt="Admin icon">
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
<!-- Multiple ERB expressions -->
|
|
56
|
+
<img src="<%= base_url %><%= image_path("logo.png") %>" alt="Logo">
|
|
57
|
+
|
|
58
|
+
<img src="<%= root_path %><%= "icon.png" %>" alt="Icon">
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## References
|
|
62
|
+
|
|
63
|
+
* [Rails `image_tag` helper documentation](https://api.rubyonrails.org/classes/ActionView/Helpers/AssetTagHelper.html#method-i-image_tag)
|
|
64
|
+
* [Rails `image_path` helper documentation](https://api.rubyonrails.org/classes/ActionView/Helpers/AssetUrlHelper.html#method-i-image_path)
|
|
65
|
+
* [Rails `asset_path` helper documentation](https://api.rubyonrails.org/classes/ActionView/Helpers/AssetUrlHelper.html#method-i-asset_path)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Linter Rule: Enforce trailing newline
|
|
2
|
+
|
|
3
|
+
**Rule:** `erb-requires-trailing-newline`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
This rule enforces that all HTML+ERB template files end with exactly one trailing newline character. This is a formatting convention widely adopted across many languages and tools.
|
|
8
|
+
|
|
9
|
+
## Rationale
|
|
10
|
+
|
|
11
|
+
Ensuring HTML+ERB files end with a single trailing newline aligns with POSIX conventions, where text files should end with a newline character.
|
|
12
|
+
|
|
13
|
+
This practice avoids unnecessary diffs from editors or formatters that auto-insert final newlines, improving compatibility with command-line tools and version control systems. It also helps maintain a clean, predictable structure across view files.
|
|
14
|
+
|
|
15
|
+
Trailing newlines are a common convention in Ruby and are enforced by tools like RuboCop and many Git-based workflows.
|
|
16
|
+
|
|
17
|
+
## Examples
|
|
18
|
+
|
|
19
|
+
### ✅ Good
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
<%= render partial: "header" %>
|
|
23
|
+
<%= render partial: "footer" %>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 🚫 Bad
|
|
27
|
+
|
|
28
|
+
```erb
|
|
29
|
+
<%= render partial: "header" %>
|
|
30
|
+
<%= render partial: "footer" %>▌
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## References
|
|
34
|
+
|
|
35
|
+
- [POSIX: Text files and trailing newlines](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_206)
|
|
36
|
+
- [Git: Trailing newlines and diffs](https://git-scm.com/docs/git-diff#_generating_patches_with_p)
|
|
37
|
+
- [EditorConfig: `insert_final_newline`](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#insert_final_newline)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Description
|
|
6
6
|
|
|
7
|
-
Disallow the use of anchor tags without
|
|
7
|
+
Disallow the use of anchor tags without an `href` attribute in HTML templates. Use if you want to perform an action without having the user navigated to a new URL.
|
|
8
8
|
|
|
9
9
|
## Rationale
|
|
10
10
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Linter Rule: `aria-level` must be between 1 and 6
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-aria-level-must-be-valid`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Ensure that the value of the `aria-level` attribute is a valid heading level: an integer between `1` and `6`. This attribute is used with `role="heading"` to indicate a heading level for non-semantic elements like `<div>` or `<span>`.
|
|
8
|
+
|
|
9
|
+
## Rationale
|
|
10
|
+
|
|
11
|
+
The WAI-ARIA specification defines `aria-level` as an integer between `1` (highest/most important) and `6` (lowest/subheading). Any other value is invalid and may confuse screen readers or fail accessibility audits.
|
|
12
|
+
|
|
13
|
+
## Examples
|
|
14
|
+
|
|
15
|
+
### ✅ Good
|
|
16
|
+
|
|
17
|
+
```erb
|
|
18
|
+
<div role="heading" aria-level="1">Main</div>
|
|
19
|
+
<div role="heading" aria-level="6">Footnote</div>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 🚫 Bad
|
|
23
|
+
|
|
24
|
+
```erb
|
|
25
|
+
<div role="heading" aria-level="-1">Negative</div>
|
|
26
|
+
|
|
27
|
+
<div role="heading" aria-level="0">Main</div>
|
|
28
|
+
|
|
29
|
+
<div role="heading" aria-level="7">Too deep</div>
|
|
30
|
+
|
|
31
|
+
<div role="heading" aria-level="foo">Invalid</div>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## References
|
|
35
|
+
|
|
36
|
+
- [ARIA: `heading` role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/heading_role)
|
|
37
|
+
- [ARIA: `aria-level` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-level)
|
|
@@ -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.
|
|
3
|
+
"version": "0.4.3",
|
|
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.
|
|
37
|
-
"@herb-tools/highlighter": "0.4.
|
|
38
|
-
"@herb-tools/node-wasm": "0.4.
|
|
36
|
+
"@herb-tools/core": "0.4.3",
|
|
37
|
+
"@herb-tools/highlighter": "0.4.3",
|
|
38
|
+
"@herb-tools/node-wasm": "0.4.3",
|
|
39
39
|
"glob": "^11.0.3"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
@@ -50,12 +50,11 @@ export class FileProcessor {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (!this.linter) {
|
|
53
|
-
this.linter = new Linter()
|
|
53
|
+
this.linter = new Linter(Herb)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const lintResult = this.linter.lint(
|
|
56
|
+
const lintResult = this.linter.lint(content, { fileName: filename })
|
|
57
57
|
|
|
58
|
-
// Get rule count on first file
|
|
59
58
|
if (ruleCount === 0) {
|
|
60
59
|
ruleCount = this.linter.getRuleCount()
|
|
61
60
|
}
|
|
@@ -65,7 +64,6 @@ export class FileProcessor {
|
|
|
65
64
|
console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`)
|
|
66
65
|
}
|
|
67
66
|
} else {
|
|
68
|
-
// Collect messages for later display
|
|
69
67
|
for (const offense of lintResult.offenses) {
|
|
70
68
|
allDiagnostics.push({ filename, diagnostic: offense, content })
|
|
71
69
|
|
package/src/default-rules.ts
CHANGED
|
@@ -2,9 +2,12 @@ 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 { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
|
|
6
|
+
import { ERBRequiresTrailingNewlineRule } from "./rules/erb-requires-trailing-newline.js"
|
|
5
7
|
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
|
|
6
8
|
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
|
|
7
9
|
import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
|
|
10
|
+
import { HTMLAriaLevelMustBeValidRule } from "./rules/html-aria-level-must-be-valid.js"
|
|
8
11
|
import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
|
|
9
12
|
import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
|
|
10
13
|
import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
|
|
@@ -17,13 +20,17 @@ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
|
|
|
17
20
|
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
|
|
18
21
|
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
|
|
19
22
|
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
|
|
23
|
+
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
|
|
20
24
|
|
|
21
25
|
export const defaultRules: RuleClass[] = [
|
|
22
26
|
ERBNoEmptyTagsRule,
|
|
23
27
|
ERBNoOutputControlFlowRule,
|
|
28
|
+
ERBPreferImageTagHelperRule,
|
|
29
|
+
ERBRequiresTrailingNewlineRule,
|
|
24
30
|
ERBRequireWhitespaceRule,
|
|
25
31
|
HTMLAnchorRequireHrefRule,
|
|
26
32
|
HTMLAriaAttributeMustBeValid,
|
|
33
|
+
HTMLAriaLevelMustBeValidRule,
|
|
27
34
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
28
35
|
HTMLAriaRoleMustBeValidRule,
|
|
29
36
|
HTMLAttributeDoubleQuotesRule,
|
|
@@ -36,4 +43,5 @@ export const defaultRules: RuleClass[] = [
|
|
|
36
43
|
HTMLNoEmptyHeadingsRule,
|
|
37
44
|
HTMLNoNestedLinksRule,
|
|
38
45
|
HTMLTagNameLowercaseRule,
|
|
46
|
+
SVGTagNameCapitalizationRule,
|
|
39
47
|
]
|
package/src/linter.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { defaultRules } from "./default-rules.js"
|
|
2
2
|
|
|
3
|
-
import type { RuleClass, LintResult, LintOffense } from "./types.js"
|
|
4
|
-
import type {
|
|
3
|
+
import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js"
|
|
4
|
+
import type { HerbBackend } from "@herb-tools/core"
|
|
5
5
|
|
|
6
6
|
export class Linter {
|
|
7
7
|
private rules: RuleClass[]
|
|
8
|
+
private herb: HerbBackend
|
|
8
9
|
private offenses: LintOffense[]
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Creates a new Linter instance.
|
|
12
|
-
* @param
|
|
13
|
+
* @param herb - The Herb backend instance for parsing and lexing
|
|
14
|
+
* @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
|
|
13
15
|
*/
|
|
14
|
-
constructor(rules?: RuleClass[]) {
|
|
16
|
+
constructor(herb: HerbBackend, rules?: RuleClass[]) {
|
|
17
|
+
this.herb = herb
|
|
15
18
|
this.rules = rules !== undefined ? rules : this.getDefaultRules()
|
|
16
19
|
this.offenses = []
|
|
17
20
|
}
|
|
@@ -28,12 +31,43 @@ export class Linter {
|
|
|
28
31
|
return this.rules.length
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Type guard to check if a rule is a LexerRule
|
|
36
|
+
*/
|
|
37
|
+
private isLexerRule(rule: Rule): rule is LexerRule {
|
|
38
|
+
return (rule.constructor as any).type === "lexer"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Type guard to check if a rule is a SourceRule
|
|
43
|
+
*/
|
|
44
|
+
private isSourceRule(rule: Rule): rule is SourceRule {
|
|
45
|
+
return (rule.constructor as any).type === "source"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lint source code using Parser/AST, Lexer, and Source rules.
|
|
50
|
+
* @param source - The source code to lint
|
|
51
|
+
* @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
|
|
52
|
+
*/
|
|
53
|
+
lint(source: string, context?: Partial<LintContext>): LintResult {
|
|
32
54
|
this.offenses = []
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
const parseResult = this.herb.parse(source)
|
|
57
|
+
const lexResult = this.herb.lex(source)
|
|
58
|
+
|
|
59
|
+
for (const RuleClass of this.rules) {
|
|
60
|
+
const rule = new RuleClass()
|
|
61
|
+
|
|
62
|
+
let ruleOffenses: LintOffense[]
|
|
63
|
+
|
|
64
|
+
if (this.isLexerRule(rule)) {
|
|
65
|
+
ruleOffenses = (rule as LexerRule).check(lexResult, context)
|
|
66
|
+
} else if (this.isSourceRule(rule)) {
|
|
67
|
+
ruleOffenses = (rule as SourceRule).check(source, context)
|
|
68
|
+
} else {
|
|
69
|
+
ruleOffenses = (rule as ParserRule).check(parseResult.value, context)
|
|
70
|
+
}
|
|
37
71
|
|
|
38
72
|
this.offenses.push(...ruleOffenses)
|
|
39
73
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { ParserRule } from "../types.js"
|
|
4
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
4
5
|
import type { Node, ERBContentNode } from "@herb-tools/core"
|
|
5
6
|
|
|
6
7
|
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
@@ -21,11 +22,11 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export class ERBNoEmptyTagsRule
|
|
25
|
+
export class ERBNoEmptyTagsRule extends ParserRule {
|
|
25
26
|
name = "erb-no-empty-tags"
|
|
26
27
|
|
|
27
|
-
check(node: Node): LintOffense[] {
|
|
28
|
-
const visitor = new ERBNoEmptyTagsVisitor(this.name)
|
|
28
|
+
check(node: Node, context?: Partial<LintContext>): LintOffense[] {
|
|
29
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.name, context)
|
|
29
30
|
|
|
30
31
|
visitor.visit(node)
|
|
31
32
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
2
|
|
|
3
3
|
import type { Node, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
|
|
4
|
-
import
|
|
4
|
+
import { ParserRule } from "../types.js"
|
|
5
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
6
|
|
|
6
7
|
class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
7
8
|
visitERBIfNode(node: ERBIfNode): void {
|
|
@@ -49,10 +50,11 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export class ERBNoOutputControlFlowRule
|
|
53
|
+
export class ERBNoOutputControlFlowRule extends ParserRule {
|
|
53
54
|
name = "erb-no-output-control-flow"
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
|
|
56
|
+
check(node: Node, context?: Partial<LintContext>): LintOffense[] {
|
|
57
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context)
|
|
56
58
|
|
|
57
59
|
visitor.visit(node)
|
|
58
60
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import { ParserRule } from "../types.js"
|
|
4
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
+
import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeValueNode, ERBContentNode, LiteralNode, Node } from "@herb-tools/core"
|
|
6
|
+
|
|
7
|
+
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
8
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
9
|
+
this.checkImgTag(node)
|
|
10
|
+
super.visitHTMLOpenTagNode(node)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
14
|
+
this.checkImgTag(node)
|
|
15
|
+
super.visitHTMLSelfCloseTagNode(node)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
19
|
+
const tagName = getTagName(node)
|
|
20
|
+
|
|
21
|
+
if (tagName !== "img") {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const attributes = getAttributes(node)
|
|
26
|
+
const srcAttribute = findAttributeByName(attributes, "src")
|
|
27
|
+
|
|
28
|
+
if (!srcAttribute) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!srcAttribute.value) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const valueNode = srcAttribute.value as HTMLAttributeValueNode
|
|
37
|
+
const hasERBContent = this.containsERBContent(valueNode)
|
|
38
|
+
|
|
39
|
+
if (hasERBContent) {
|
|
40
|
+
const suggestedExpression = this.buildSuggestedExpression(valueNode)
|
|
41
|
+
|
|
42
|
+
this.addOffense(
|
|
43
|
+
`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
|
|
44
|
+
srcAttribute.location,
|
|
45
|
+
"warning"
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private containsERBContent(valueNode: HTMLAttributeValueNode): boolean {
|
|
51
|
+
if (!valueNode.children) return false
|
|
52
|
+
|
|
53
|
+
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private buildSuggestedExpression(valueNode: HTMLAttributeValueNode): string {
|
|
57
|
+
if (!valueNode.children) return "expression"
|
|
58
|
+
|
|
59
|
+
let hasText = false
|
|
60
|
+
let hasERB = false
|
|
61
|
+
|
|
62
|
+
for (const child of valueNode.children) {
|
|
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
|
|
67
|
+
|
|
68
|
+
if (literalNode.content && literalNode.content.trim()) {
|
|
69
|
+
hasText = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (hasText && hasERB) {
|
|
75
|
+
let result = '"'
|
|
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
|
+
}
|
|
88
|
+
|
|
89
|
+
result += '"'
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (hasERB && !hasText) {
|
|
95
|
+
const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE") as ERBContentNode[]
|
|
96
|
+
|
|
97
|
+
if (erbNodes.length === 1) {
|
|
98
|
+
return (erbNodes[0].content?.value || "").trim()
|
|
99
|
+
} else if (erbNodes.length > 1) {
|
|
100
|
+
let result = '"'
|
|
101
|
+
|
|
102
|
+
for (const erbNode of erbNodes) {
|
|
103
|
+
result += `#{${(erbNode.content?.value || "").trim()}}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
result += '"'
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return "expression"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class ERBPreferImageTagHelperRule extends ParserRule {
|
|
117
|
+
name = "erb-prefer-image-tag-helper"
|
|
118
|
+
|
|
119
|
+
check(node: Node, context?: Partial<LintContext>): LintOffense[] {
|
|
120
|
+
const visitor = new ERBPreferImageTagHelperVisitor(this.name, context)
|
|
121
|
+
visitor.visit(node)
|
|
122
|
+
return visitor.offenses
|
|
123
|
+
}
|
|
124
|
+
}
|