@herb-tools/linter 0.6.0 → 0.7.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 +60 -16
- package/dist/herb-lint.js +1684 -295
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1226 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1188 -160
- package/dist/index.js.map +1 -1
- package/dist/package.json +11 -4
- package/dist/src/cli/argument-parser.js +11 -6
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +5 -6
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +3 -5
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
- package/dist/src/cli/index.js +1 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/output-manager.js +23 -5
- package/dist/src/cli/output-manager.js.map +1 -1
- package/dist/src/cli/summary-reporter.js +2 -11
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +88 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +8 -4
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
- package/dist/src/rules/html-boolean-attributes-no-value.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-attributes.js +56 -0
- package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
- package/dist/src/rules/html-no-positive-tab-index.js +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
- package/dist/src/rules/html-no-self-closing.js +12 -5
- package/dist/src/rules/html-no-self-closing.js.map +1 -1
- package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
- package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
- package/dist/src/rules/index.js +3 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +80 -7
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
- package/dist/src/rules/svg-tag-name-capitalization.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 -1
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/output-manager.d.ts +1 -0
- package/dist/types/cli.d.ts +20 -5
- package/dist/types/linter.d.ts +7 -7
- package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/rule-utils.d.ts +46 -5
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -1
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/cli/output-manager.d.ts +1 -0
- package/dist/types/src/cli.d.ts +20 -5
- package/dist/types/src/linter.d.ts +7 -7
- package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/src/rules/index.d.ts +3 -0
- package/dist/types/src/rules/rule-utils.d.ts +46 -5
- package/docs/rules/README.md +2 -0
- package/docs/rules/html-img-require-alt.md +0 -2
- package/docs/rules/html-no-empty-attributes.md +77 -0
- package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
- package/package.json +11 -4
- package/src/cli/argument-parser.ts +15 -7
- package/src/cli/file-processor.ts +11 -7
- package/src/cli/formatters/detailed-formatter.ts +5 -7
- package/src/cli/formatters/github-actions-formatter.ts +64 -11
- package/src/cli/index.ts +2 -0
- package/src/cli/output-manager.ts +27 -5
- package/src/cli/summary-reporter.ts +3 -11
- package/src/cli.ts +125 -20
- package/src/default-rules.ts +8 -4
- package/src/linter.ts +6 -6
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
- package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
- package/src/rules/html-attribute-double-quotes.ts +1 -1
- package/src/rules/html-boolean-attributes-no-value.ts +9 -11
- package/src/rules/html-no-duplicate-ids.ts +188 -14
- package/src/rules/html-no-empty-attributes.ts +75 -0
- package/src/rules/html-no-positive-tab-index.ts +1 -1
- package/src/rules/html-no-self-closing.ts +13 -8
- package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
- package/src/rules/html-tag-name-lowercase.ts +1 -1
- package/src/rules/index.ts +3 -0
- package/src/rules/rule-utils.ts +110 -9
- package/src/rules/svg-tag-name-capitalization.ts +2 -2
package/dist/types/src/cli.d.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import { ArgumentParser, type FormatOption } from "./cli/argument-parser.js";
|
|
2
|
+
import { FileProcessor } from "./cli/file-processor.js";
|
|
3
|
+
import { OutputManager } from "./cli/output-manager.js";
|
|
4
|
+
export * from "./cli/index.js";
|
|
1
5
|
export declare class CLI {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
protected argumentParser: ArgumentParser;
|
|
7
|
+
protected fileProcessor: FileProcessor;
|
|
8
|
+
protected outputManager: OutputManager;
|
|
9
|
+
protected projectPath: string;
|
|
10
|
+
getProjectPath(): string;
|
|
11
|
+
protected findProjectRoot(startPath: string): string;
|
|
12
|
+
protected exitWithError(message: string, formatOption: FormatOption, exitCode?: number): void;
|
|
13
|
+
protected exitWithInfo(message: string, formatOption: FormatOption, exitCode?: number, timingData?: {
|
|
14
|
+
startTime: number;
|
|
15
|
+
startDate: Date;
|
|
16
|
+
showTiming: boolean;
|
|
17
|
+
}): void;
|
|
18
|
+
protected determineProjectPath(pattern: string | undefined): void;
|
|
19
|
+
protected adjustPattern(pattern: string | undefined): string;
|
|
20
|
+
protected beforeProcess(): Promise<void>;
|
|
21
|
+
protected afterProcess(_results: any, _outputOptions: any): Promise<void>;
|
|
7
22
|
run(): Promise<void>;
|
|
8
23
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { RuleClass, LintResult, LintContext } from "./types.js";
|
|
1
|
+
import type { RuleClass, Rule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js";
|
|
2
2
|
import type { HerbBackend } from "@herb-tools/core";
|
|
3
3
|
export declare class Linter {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
protected rules: RuleClass[];
|
|
5
|
+
protected herb: HerbBackend;
|
|
6
|
+
protected offenses: LintOffense[];
|
|
7
7
|
/**
|
|
8
8
|
* Creates a new Linter instance.
|
|
9
9
|
* @param herb - The Herb backend instance for parsing and lexing
|
|
@@ -14,16 +14,16 @@ export declare class Linter {
|
|
|
14
14
|
* Returns the default set of rule classes used by the linter.
|
|
15
15
|
* @returns Array of rule classes
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
protected getDefaultRules(): RuleClass[];
|
|
18
18
|
getRuleCount(): number;
|
|
19
19
|
/**
|
|
20
20
|
* Type guard to check if a rule is a LexerRule
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
protected isLexerRule(rule: Rule): rule is LexerRule;
|
|
23
23
|
/**
|
|
24
24
|
* Type guard to check if a rule is a SourceRule
|
|
25
25
|
*/
|
|
26
|
-
|
|
26
|
+
protected isSourceRule(rule: Rule): rule is SourceRule;
|
|
27
27
|
/**
|
|
28
28
|
* Lint source code using Parser/AST, Lexer, and Source rules.
|
|
29
29
|
* @param source - The source code to lint
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js";
|
|
2
|
+
import type { LintOffense, LintContext } from "../types.js";
|
|
3
|
+
import type { ParseResult } from "@herb-tools/core";
|
|
4
|
+
export declare class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
5
|
+
name: string;
|
|
6
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[];
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js";
|
|
2
|
+
import type { LintContext, LintOffense } from "../types.js";
|
|
3
|
+
import type { ParseResult } from "@herb-tools/core";
|
|
4
|
+
export declare class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
|
|
5
|
+
name: string;
|
|
6
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[];
|
|
7
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./rule-utils.js";
|
|
1
2
|
export * from "./erb-no-empty-tags.js";
|
|
2
3
|
export * from "./erb-no-output-control-flow.js";
|
|
3
4
|
export * from "./erb-no-silent-tag-in-attribute-name.js";
|
|
@@ -20,6 +21,7 @@ export * from "./html-no-aria-hidden-on-focusable.js";
|
|
|
20
21
|
export * from "./html-no-block-inside-inline.js";
|
|
21
22
|
export * from "./html-no-duplicate-attributes.js";
|
|
22
23
|
export * from "./html-no-duplicate-ids.js";
|
|
24
|
+
export * from "./html-no-empty-attributes.js";
|
|
23
25
|
export * from "./html-no-empty-headings.js";
|
|
24
26
|
export * from "./html-no-nested-links.js";
|
|
25
27
|
export * from "./html-no-positive-tab-index.js";
|
|
@@ -27,3 +29,4 @@ export * from "./html-no-self-closing.js";
|
|
|
27
29
|
export * from "./html-no-title-attribute.js";
|
|
28
30
|
export * from "./html-tag-name-lowercase.js";
|
|
29
31
|
export * from "./svg-tag-name-capitalization.js";
|
|
32
|
+
export * from "./html-no-underscores-in-attribute-names.js";
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Visitor, Location } from "@herb-tools/core";
|
|
2
2
|
import type { HTMLAttributeNode, HTMLAttributeValueNode, HTMLOpenTagNode, LexResult, Token, Node } from "@herb-tools/core";
|
|
3
|
+
import type * as Nodes from "@herb-tools/core";
|
|
3
4
|
import type { LintOffense, LintSeverity, LintContext } from "../types.js";
|
|
5
|
+
export declare enum ControlFlowType {
|
|
6
|
+
CONDITIONAL = 0,
|
|
7
|
+
LOOP = 1
|
|
8
|
+
}
|
|
4
9
|
/**
|
|
5
10
|
* Base visitor class that provides common functionality for rule visitors
|
|
6
11
|
*/
|
|
@@ -18,6 +23,40 @@ export declare abstract class BaseRuleVisitor extends Visitor {
|
|
|
18
23
|
*/
|
|
19
24
|
protected addOffense(message: string, location: Location, severity?: LintSeverity): void;
|
|
20
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Mixin that adds control flow tracking capabilities to rule visitors
|
|
28
|
+
* This allows rules to track state across different control flow structures
|
|
29
|
+
* like if/else branches, loops, etc.
|
|
30
|
+
*
|
|
31
|
+
* @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
|
|
32
|
+
* @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
|
|
33
|
+
*/
|
|
34
|
+
export declare abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
|
|
35
|
+
protected isInControlFlow: boolean;
|
|
36
|
+
protected currentControlFlowType: ControlFlowType | null;
|
|
37
|
+
/**
|
|
38
|
+
* Handle visiting a control flow node with proper scope management
|
|
39
|
+
*/
|
|
40
|
+
protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void;
|
|
41
|
+
/**
|
|
42
|
+
* Handle visiting a branch node (like else, when) with proper scope management
|
|
43
|
+
*/
|
|
44
|
+
protected startNewBranch(visitChildren: () => void): void;
|
|
45
|
+
visitERBIfNode(node: Nodes.ERBIfNode): void;
|
|
46
|
+
visitERBUnlessNode(node: Nodes.ERBUnlessNode): void;
|
|
47
|
+
visitERBCaseNode(node: Nodes.ERBCaseNode): void;
|
|
48
|
+
visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void;
|
|
49
|
+
visitERBWhileNode(node: Nodes.ERBWhileNode): void;
|
|
50
|
+
visitERBForNode(node: Nodes.ERBForNode): void;
|
|
51
|
+
visitERBUntilNode(node: Nodes.ERBUntilNode): void;
|
|
52
|
+
visitERBBlockNode(node: Nodes.ERBBlockNode): void;
|
|
53
|
+
visitERBElseNode(node: Nodes.ERBElseNode): void;
|
|
54
|
+
visitERBWhenNode(node: Nodes.ERBWhenNode): void;
|
|
55
|
+
protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState;
|
|
56
|
+
protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void;
|
|
57
|
+
protected abstract onEnterBranch(): TBranchState;
|
|
58
|
+
protected abstract onExitBranch(stateToRestore: TBranchState): void;
|
|
59
|
+
}
|
|
21
60
|
/**
|
|
22
61
|
* Gets attributes from an HTMLOpenTagNode
|
|
23
62
|
*/
|
|
@@ -30,7 +69,7 @@ export declare function getTagName(node: HTMLOpenTagNode): string | null;
|
|
|
30
69
|
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
31
70
|
* Returns null if the attribute name contains dynamic content (ERB)
|
|
32
71
|
*/
|
|
33
|
-
export declare function getAttributeName(attributeNode: HTMLAttributeNode): string | null;
|
|
72
|
+
export declare function getAttributeName(attributeNode: HTMLAttributeNode, lowercase?: boolean): string | null;
|
|
34
73
|
/**
|
|
35
74
|
* Checks if an attribute has a dynamic (ERB-containing) name
|
|
36
75
|
*/
|
|
@@ -114,12 +153,14 @@ export interface StaticAttributeStaticValueParams {
|
|
|
114
153
|
attributeName: string;
|
|
115
154
|
attributeValue: string;
|
|
116
155
|
attributeNode: HTMLAttributeNode;
|
|
156
|
+
originalAttributeName: string;
|
|
117
157
|
parentNode: HTMLOpenTagNode;
|
|
118
158
|
}
|
|
119
159
|
export interface StaticAttributeDynamicValueParams {
|
|
120
160
|
attributeName: string;
|
|
121
161
|
valueNodes: Node[];
|
|
122
162
|
attributeNode: HTMLAttributeNode;
|
|
163
|
+
originalAttributeName: string;
|
|
123
164
|
parentNode: HTMLOpenTagNode;
|
|
124
165
|
combinedValue?: string | null;
|
|
125
166
|
}
|
|
@@ -176,19 +217,19 @@ export declare abstract class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
176
217
|
/**
|
|
177
218
|
* Static attribute name with static value: class="container"
|
|
178
219
|
*/
|
|
179
|
-
protected checkStaticAttributeStaticValue(
|
|
220
|
+
protected checkStaticAttributeStaticValue(_params: StaticAttributeStaticValueParams): void;
|
|
180
221
|
/**
|
|
181
222
|
* Static attribute name with dynamic value: class="<%= css_class %>"
|
|
182
223
|
*/
|
|
183
|
-
protected checkStaticAttributeDynamicValue(
|
|
224
|
+
protected checkStaticAttributeDynamicValue(_params: StaticAttributeDynamicValueParams): void;
|
|
184
225
|
/**
|
|
185
226
|
* Dynamic attribute name with static value: data-<%= key %>="foo"
|
|
186
227
|
*/
|
|
187
|
-
protected checkDynamicAttributeStaticValue(
|
|
228
|
+
protected checkDynamicAttributeStaticValue(_params: DynamicAttributeStaticValueParams): void;
|
|
188
229
|
/**
|
|
189
230
|
* Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
|
|
190
231
|
*/
|
|
191
|
-
protected checkDynamicAttributeDynamicValue(
|
|
232
|
+
protected checkDynamicAttributeDynamicValue(_params: DynamicAttributeDynamicValueParams): void;
|
|
192
233
|
}
|
|
193
234
|
/**
|
|
194
235
|
* Checks if an attribute value is quoted
|
package/docs/rules/README.md
CHANGED
|
@@ -28,11 +28,13 @@ This page contains documentation for all Herb Linter rules.
|
|
|
28
28
|
- [`html-no-block-inside-inline`](./html-no-block-inside-inline.md) - Prevents block-level elements inside inline elements
|
|
29
29
|
- [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
|
|
30
30
|
- [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
|
|
31
|
+
- [`html-no-empty-attributes`](./html-no-empty-attributes.md) - Attributes must not have empty values
|
|
31
32
|
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
|
|
32
33
|
- [`html-no-positive-tab-index`](./html-no-positive-tab-index.md) - Avoid positive `tabindex` values
|
|
33
34
|
- [`html-no-self-closing`](./html-no-self-closing.md.md) - Disallow self closing tags
|
|
34
35
|
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
|
|
35
36
|
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
|
|
37
|
+
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
|
|
36
38
|
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
|
|
37
39
|
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
|
|
38
40
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Linter Rule: Attributes must not have empty values
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-no-empty-attributes`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Warn when certain restricted attributes are present but have an empty string as their value. These attributes are required to have meaningful values to function properly, and leaving them empty is typically either a mistake or unnecessary.
|
|
8
|
+
|
|
9
|
+
In most cases, if the value is not available, it's better to omit the attribute entirely.
|
|
10
|
+
|
|
11
|
+
### Restricted attributes
|
|
12
|
+
|
|
13
|
+
- `id`
|
|
14
|
+
- `class`
|
|
15
|
+
- `name`
|
|
16
|
+
- `for`
|
|
17
|
+
- `src`
|
|
18
|
+
- `href`
|
|
19
|
+
- `title`
|
|
20
|
+
- `data`
|
|
21
|
+
- `role`
|
|
22
|
+
- `data-*`
|
|
23
|
+
- `aria-*`
|
|
24
|
+
|
|
25
|
+
## Rationale
|
|
26
|
+
|
|
27
|
+
Many HTML attributes are only useful when they carry a value. Leaving these attributes empty can:
|
|
28
|
+
|
|
29
|
+
- Produce confusing or misleading markup (e.g., `id=""`, `class=""`)
|
|
30
|
+
- Create inaccessible or invalid HTML
|
|
31
|
+
- Interfere with CSS or JS selectors expecting meaningful values
|
|
32
|
+
- Indicate unused or unfinished logic in ERB
|
|
33
|
+
|
|
34
|
+
This rule helps ensure that required attributes are only added when they are populated.
|
|
35
|
+
|
|
36
|
+
## Examples
|
|
37
|
+
|
|
38
|
+
### ✅ Good
|
|
39
|
+
|
|
40
|
+
```erb
|
|
41
|
+
<div id="header"></div>
|
|
42
|
+
<img src="/logo.png" alt="Company logo">
|
|
43
|
+
<input type="text" name="email">
|
|
44
|
+
|
|
45
|
+
<!-- Dynamic attributes with meaningful values -->
|
|
46
|
+
<div data-<%= key %>="<%= value %>" aria-<%= prop %>="<%= description %>">
|
|
47
|
+
Dynamic content
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- if no class should be set, omit it completely -->
|
|
51
|
+
<div>Plain div</div>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 🚫 Bad
|
|
55
|
+
|
|
56
|
+
```erb
|
|
57
|
+
<div id=""></div>
|
|
58
|
+
<img src="">
|
|
59
|
+
<input name="">
|
|
60
|
+
|
|
61
|
+
<div data-config="">Content</div>
|
|
62
|
+
<button aria-label="">×</button>
|
|
63
|
+
|
|
64
|
+
<div class="">Plain div</div>
|
|
65
|
+
|
|
66
|
+
<!-- Dynamic attribute names with empty static values -->
|
|
67
|
+
<div data-<%= key %>="" aria-<%= prop %>=" ">
|
|
68
|
+
Problematic dynamic attributes
|
|
69
|
+
</div>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## References
|
|
73
|
+
|
|
74
|
+
- [HTML Living Standard - Global attributes](https://html.spec.whatwg.org/multipage/dom.html#global-attributes)
|
|
75
|
+
- [WCAG 2.2 - Text Alternatives](https://www.w3.org/WAI/WCAG22/Understanding/text-alternatives.html)
|
|
76
|
+
- [MDN - HTML attribute reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes)
|
|
77
|
+
- [ARIA in HTML](https://www.w3.org/TR/html-aria/)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Linter Rule: No underscores on attribute names
|
|
2
|
+
|
|
3
|
+
**Rule:** `html-no-underscores-in-attribute-names`
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Warn when an HTML attribute name contains an underscore (`_`). According to the HTML specification, attribute names should use only lowercase letters, digits, hyphens (`-`), and colons (`:`) in specific namespaces (e.g., `xlink:href` in SVG). Underscores are not valid in standard HTML attribute names and may lead to unpredictable behavior or be ignored by browsers entirely.
|
|
10
|
+
|
|
11
|
+
## Rationale
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
Underscores in attribute names violate the HTML specification and are not supported in standard markup. Their use is almost always accidental (e.g., mistyping `data-attr_name` instead of `data-attr-name`) or stems from inconsistent naming conventions across backend or templating layers.
|
|
16
|
+
|
|
17
|
+
## Examples
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
✅ Good
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<div data-user-id="123"></div>
|
|
25
|
+
|
|
26
|
+
<img aria-label="Close">
|
|
27
|
+
|
|
28
|
+
<div data-<%= key %>-attribute="value"></div>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
🚫 Bad
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<div data_user_id="123"></div>
|
|
35
|
+
|
|
36
|
+
<img aria_label="Close">
|
|
37
|
+
|
|
38
|
+
<div data-<%= key %>_attribute="value"></div>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## References
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
\-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herb-tools/linter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "HTML+ERB linter for validating HTML structure and enforcing best practices",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://herb-tools.dev",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"build": "yarn clean && tsc -b && rollup -c",
|
|
22
22
|
"watch": "tsc -b -w",
|
|
23
23
|
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest --watch",
|
|
24
25
|
"prepublishOnly": "yarn clean && yarn build && yarn test"
|
|
25
26
|
},
|
|
26
27
|
"exports": {
|
|
@@ -30,12 +31,18 @@
|
|
|
30
31
|
"import": "./dist/index.js",
|
|
31
32
|
"require": "./dist/index.cjs",
|
|
32
33
|
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./cli": {
|
|
36
|
+
"types": "./dist/types/src/cli.d.ts",
|
|
37
|
+
"require": "./dist/src/cli.js",
|
|
38
|
+
"default": "./dist/src/cli.js"
|
|
33
39
|
}
|
|
34
40
|
},
|
|
35
41
|
"dependencies": {
|
|
36
|
-
"@herb-tools/core": "0.
|
|
37
|
-
"@herb-tools/highlighter": "0.
|
|
38
|
-
"@herb-tools/node-wasm": "0.
|
|
42
|
+
"@herb-tools/core": "0.7.0",
|
|
43
|
+
"@herb-tools/highlighter": "0.7.0",
|
|
44
|
+
"@herb-tools/node-wasm": "0.7.0",
|
|
45
|
+
"@herb-tools/printer": "0.7.0",
|
|
39
46
|
"glob": "^11.0.3"
|
|
40
47
|
},
|
|
41
48
|
"files": [
|
|
@@ -11,7 +11,7 @@ import type { ThemeInput } from "@herb-tools/highlighter"
|
|
|
11
11
|
|
|
12
12
|
import { name, version } from "../../package.json"
|
|
13
13
|
|
|
14
|
-
export type FormatOption = "simple" | "detailed" | "json"
|
|
14
|
+
export type FormatOption = "simple" | "detailed" | "json"
|
|
15
15
|
|
|
16
16
|
export interface ParsedArguments {
|
|
17
17
|
pattern: string
|
|
@@ -20,6 +20,7 @@ export interface ParsedArguments {
|
|
|
20
20
|
theme: ThemeInput
|
|
21
21
|
wrapLines: boolean
|
|
22
22
|
truncateLines: boolean
|
|
23
|
+
useGitHubActions: boolean
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export class ArgumentParser {
|
|
@@ -34,10 +35,11 @@ export class ArgumentParser {
|
|
|
34
35
|
Options:
|
|
35
36
|
-h, --help show help
|
|
36
37
|
-v, --version show version
|
|
37
|
-
--format output format (simple|detailed|json
|
|
38
|
+
--format output format (simple|detailed|json) [default: detailed]
|
|
38
39
|
--simple use simple output format (shortcut for --format simple)
|
|
39
40
|
--json use JSON output format (shortcut for --format json)
|
|
40
|
-
--github
|
|
41
|
+
--github enable GitHub Actions annotations (combines with --format)
|
|
42
|
+
--no-github disable GitHub Actions annotations (even in GitHub Actions environment)
|
|
41
43
|
--theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}]
|
|
42
44
|
--no-color disable colored output
|
|
43
45
|
--no-timing hide timing information
|
|
@@ -55,6 +57,7 @@ export class ArgumentParser {
|
|
|
55
57
|
simple: { type: "boolean" },
|
|
56
58
|
json: { type: "boolean" },
|
|
57
59
|
github: { type: "boolean" },
|
|
60
|
+
"no-github": { type: "boolean" },
|
|
58
61
|
theme: { type: "string" },
|
|
59
62
|
"no-color": { type: "boolean" },
|
|
60
63
|
"no-timing": { type: "boolean" },
|
|
@@ -75,8 +78,10 @@ export class ArgumentParser {
|
|
|
75
78
|
process.exit(0)
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
const isGitHubActions = process.env.GITHUB_ACTIONS === "true"
|
|
82
|
+
|
|
78
83
|
let formatOption: FormatOption = "detailed"
|
|
79
|
-
if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json"
|
|
84
|
+
if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json")) {
|
|
80
85
|
formatOption = values.format
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -88,8 +93,11 @@ export class ArgumentParser {
|
|
|
88
93
|
formatOption = "json"
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
const useGitHubActions = (values.github || isGitHubActions) && !values["no-github"]
|
|
97
|
+
|
|
98
|
+
if (useGitHubActions && formatOption === "json") {
|
|
99
|
+
console.error("Error: --github cannot be used with --json format. JSON format is already structured for programmatic consumption.")
|
|
100
|
+
process.exit(1)
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
if (values["no-color"]) {
|
|
@@ -114,7 +122,7 @@ export class ArgumentParser {
|
|
|
114
122
|
const theme = values.theme || DEFAULT_THEME
|
|
115
123
|
const pattern = this.getFilePattern(positionals)
|
|
116
124
|
|
|
117
|
-
return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines }
|
|
125
|
+
return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions }
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
private getFilePattern(positionals: string[]): string {
|
|
@@ -12,6 +12,11 @@ export interface ProcessedFile {
|
|
|
12
12
|
content: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface ProcessingContext {
|
|
16
|
+
projectPath?: string
|
|
17
|
+
pattern?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
export interface ProcessingResult {
|
|
16
21
|
totalErrors: number
|
|
17
22
|
totalWarnings: number
|
|
@@ -19,12 +24,13 @@ export interface ProcessingResult {
|
|
|
19
24
|
ruleCount: number
|
|
20
25
|
allOffenses: ProcessedFile[]
|
|
21
26
|
ruleOffenses: Map<string, { count: number, files: Set<string> }>
|
|
27
|
+
context?: ProcessingContext
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export class FileProcessor {
|
|
25
31
|
private linter: Linter | null = null
|
|
26
32
|
|
|
27
|
-
async processFiles(files: string[], formatOption: FormatOption = 'detailed'): Promise<ProcessingResult> {
|
|
33
|
+
async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {
|
|
28
34
|
let totalErrors = 0
|
|
29
35
|
let totalWarnings = 0
|
|
30
36
|
let filesWithOffenses = 0
|
|
@@ -33,13 +39,12 @@ export class FileProcessor {
|
|
|
33
39
|
const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()
|
|
34
40
|
|
|
35
41
|
for (const filename of files) {
|
|
36
|
-
const filePath = resolve(filename)
|
|
42
|
+
const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename)
|
|
37
43
|
const content = readFileSync(filePath, "utf-8")
|
|
38
|
-
|
|
39
44
|
const parseResult = Herb.parse(content)
|
|
40
45
|
|
|
41
46
|
if (parseResult.errors.length > 0) {
|
|
42
|
-
if (formatOption !== 'json'
|
|
47
|
+
if (formatOption !== 'json') {
|
|
43
48
|
console.error(`${colorize(filename, "cyan")} - ${colorize("Parse errors:", "brightRed")}`)
|
|
44
49
|
|
|
45
50
|
for (const error of parseResult.errors) {
|
|
@@ -47,7 +52,6 @@ export class FileProcessor {
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
// Add parse errors to offenses for JSON output
|
|
51
55
|
for (const error of parseResult.errors) {
|
|
52
56
|
allOffenses.push({ filename, offense: error, content })
|
|
53
57
|
}
|
|
@@ -68,7 +72,7 @@ export class FileProcessor {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
if (lintResult.offenses.length === 0) {
|
|
71
|
-
if (files.length === 1 && formatOption !== 'json'
|
|
75
|
+
if (files.length === 1 && formatOption !== 'json') {
|
|
72
76
|
console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`)
|
|
73
77
|
}
|
|
74
78
|
} else {
|
|
@@ -87,6 +91,6 @@ export class FileProcessor {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
return { totalErrors, totalWarnings, filesWithOffenses, ruleCount, allOffenses, ruleOffenses }
|
|
94
|
+
return { totalErrors, totalWarnings, filesWithOffenses, ruleCount, allOffenses, ruleOffenses, context }
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { colorize, Highlighter, type ThemeInput, DEFAULT_THEME } from "@herb-tools/highlighter"
|
|
2
2
|
|
|
3
3
|
import { BaseFormatter } from "./base-formatter.js"
|
|
4
|
+
import { LineWrapper } from "@herb-tools/highlighter"
|
|
4
5
|
|
|
5
6
|
import type { Diagnostic } from "@herb-tools/core"
|
|
6
7
|
import type { ProcessedFile } from "../file-processor.js"
|
|
@@ -27,13 +28,12 @@ export class DetailedFormatter extends BaseFormatter {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
if (isSingleFile) {
|
|
30
|
-
// For single file, use inline diagnostics with syntax highlighting
|
|
31
31
|
const { filename, content } = allOffenses[0]
|
|
32
32
|
const diagnostics = allOffenses.map(item => item.offense)
|
|
33
33
|
|
|
34
34
|
const highlighted = this.highlighter.highlight(filename, content, {
|
|
35
35
|
diagnostics: diagnostics,
|
|
36
|
-
splitDiagnostics: true,
|
|
36
|
+
splitDiagnostics: true,
|
|
37
37
|
contextLines: 2,
|
|
38
38
|
wrapLines: this.wrapLines,
|
|
39
39
|
truncateLines: this.truncateLines
|
|
@@ -41,19 +41,18 @@ export class DetailedFormatter extends BaseFormatter {
|
|
|
41
41
|
|
|
42
42
|
console.log(`\n${highlighted}`)
|
|
43
43
|
} else {
|
|
44
|
-
// For multiple files, show individual diagnostics with syntax highlighting
|
|
45
44
|
const totalMessageCount = allOffenses.length
|
|
46
45
|
|
|
47
46
|
for (let i = 0; i < allOffenses.length; i++) {
|
|
48
47
|
const { filename, offense, content } = allOffenses[i]
|
|
49
|
-
const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, {
|
|
50
|
-
contextLines: 2,
|
|
48
|
+
const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, {
|
|
49
|
+
contextLines: 2,
|
|
51
50
|
wrapLines: this.wrapLines,
|
|
52
51
|
truncateLines: this.truncateLines
|
|
53
52
|
})
|
|
54
53
|
console.log(`\n${formatted}`)
|
|
55
54
|
|
|
56
|
-
const width =
|
|
55
|
+
const width = LineWrapper.getTerminalWidth()
|
|
57
56
|
const progressText = `[${i + 1}/${totalMessageCount}]`
|
|
58
57
|
const rightPadding = 16
|
|
59
58
|
const separatorLength = Math.max(0, width - progressText.length - 1 - rightPadding)
|
|
@@ -68,7 +67,6 @@ export class DetailedFormatter extends BaseFormatter {
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
formatFile(_filename: string, _offenses: Diagnostic[]): void {
|
|
71
|
-
// Not used in detailed formatter
|
|
72
70
|
throw new Error("formatFile is not implemented for DetailedFormatter")
|
|
73
71
|
}
|
|
74
72
|
}
|