@herb-tools/linter 0.8.7 → 0.8.8
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 +28 -2
- package/dist/herb-lint.js +5406 -15659
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +381 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +376 -39
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +1231 -7911
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1225 -7912
- package/dist/loader.js.map +1 -1
- package/dist/package.json +7 -7
- package/dist/src/cli/argument-parser.js +5 -2
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +1 -1
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli.js +14 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/custom-rule-loader.js +2 -2
- package/dist/src/custom-rule-loader.js.map +1 -1
- package/dist/src/linter.js +14 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/src/rules/erb-strict-locals-required.js +38 -0
- package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/src/rules/file-utils.js +21 -0
- package/dist/src/rules/file-utils.js.map +1 -0
- package/dist/src/rules/html-head-only-elements.js +2 -0
- package/dist/src/rules/html-head-only-elements.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +22 -36
- package/dist/src/rules/html-no-empty-headings.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/string-utils.js +72 -0
- package/dist/src/rules/string-utils.js.map +1 -0
- package/dist/src/rules.js +4 -0
- package/dist/src/rules.js.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +1 -0
- package/dist/types/cli/file-processor.d.ts +1 -0
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/linter.d.ts +5 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/rules/file-utils.d.ts +13 -0
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/string-utils.d.ts +15 -0
- package/dist/types/src/cli/argument-parser.d.ts +1 -0
- package/dist/types/src/cli/file-processor.d.ts +1 -0
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/linter.d.ts +5 -1
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/src/rules/file-utils.d.ts +13 -0
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/string-utils.d.ts +15 -0
- package/dist/types/src/types.d.ts +6 -0
- package/dist/types/types.d.ts +6 -0
- package/docs/rules/README.md +1 -0
- package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
- package/docs/rules/erb-strict-locals-required.md +107 -0
- package/package.json +7 -7
- package/src/cli/argument-parser.ts +6 -2
- package/src/cli/file-processor.ts +2 -1
- package/src/cli.ts +18 -8
- package/src/custom-rule-loader.ts +2 -2
- package/src/linter.ts +17 -1
- package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
- package/src/rules/erb-strict-locals-required.ts +52 -0
- package/src/rules/file-utils.ts +23 -0
- package/src/rules/html-head-only-elements.ts +1 -0
- package/src/rules/html-no-empty-headings.ts +21 -44
- package/src/rules/index.ts +4 -0
- package/src/rules/string-utils.ts +72 -0
- package/src/rules.ts +4 -0
- package/src/types.ts +6 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
import { ParserRule } from "../types.js"
|
|
3
|
+
|
|
4
|
+
import { isPartialFile } from "./file-utils.js"
|
|
5
|
+
import { hasBalancedParentheses, splitByTopLevelComma } from "./string-utils.js"
|
|
6
|
+
|
|
7
|
+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
8
|
+
import type { ParseResult, ERBContentNode } from "@herb-tools/core"
|
|
9
|
+
|
|
10
|
+
export const STRICT_LOCALS_PATTERN = /^locals:\s*\([^)]*\)\s*$/
|
|
11
|
+
|
|
12
|
+
function isValidStrictLocalsFormat(content: string): boolean {
|
|
13
|
+
return STRICT_LOCALS_PATTERN.test(content)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractERBCommentContent(content: string): string {
|
|
17
|
+
return content.trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractRubyCommentContent(content: string): string | null {
|
|
21
|
+
const match = content.match(/^\s*#\s*(.*)$/)
|
|
22
|
+
|
|
23
|
+
return match ? match[1].trim() : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractLocalsRemainder(content: string): string | null {
|
|
27
|
+
const match = content.match(/^locals?\b(.*)$/)
|
|
28
|
+
|
|
29
|
+
return match ? match[1] : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function looksLikeLocalsDeclaration(content: string): boolean {
|
|
33
|
+
return /^locals?\b/.test(content) && /[(:)]/.test(content)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasLocalsLikeSyntax(remainder: string): boolean {
|
|
37
|
+
return /[(:)]/.test(remainder)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectLocalsWithoutColon(content: string): boolean {
|
|
41
|
+
return /^locals?\(/.test(content)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectSingularLocal(content: string): boolean {
|
|
45
|
+
return /^local:/.test(content)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detectMissingColonBeforeParens(content: string): boolean {
|
|
49
|
+
return /^locals\s+\(/.test(content)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function detectMissingParentheses(content: string): boolean {
|
|
53
|
+
return /^locals:\s*[^(]/.test(content)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detectEmptyLocalsWithoutParens(content: string): boolean {
|
|
57
|
+
return /^locals:\s*$/.test(content)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateCommaUsage(inner: string): string | null {
|
|
61
|
+
if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
|
|
62
|
+
return "Unexpected comma in `locals:` parameters."
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateBlockArgument(param: string): string | null {
|
|
69
|
+
if (param.startsWith("&")) {
|
|
70
|
+
return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateSplatArgument(param: string): string | null {
|
|
77
|
+
if (param.startsWith("*") && !param.startsWith("**")) {
|
|
78
|
+
return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateDoubleSplatArgument(param: string): string | null {
|
|
85
|
+
if (param.startsWith("**")) {
|
|
86
|
+
if (/^\*\*\w+$/.test(param)) {
|
|
87
|
+
return null // Valid double-splat
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateKeywordArgument(param: string): string | null {
|
|
97
|
+
if (!/^\w+:\s*/.test(param)) {
|
|
98
|
+
if (/^\w+$/.test(param)) {
|
|
99
|
+
return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateParameter(param: string): string | null {
|
|
109
|
+
const trimmed = param.trim()
|
|
110
|
+
|
|
111
|
+
if (!trimmed) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
validateBlockArgument(trimmed) ||
|
|
115
|
+
validateSplatArgument(trimmed) ||
|
|
116
|
+
validateDoubleSplatArgument(trimmed) ||
|
|
117
|
+
(trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed))
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateLocalsSignature(paramsContent: string): string | null {
|
|
122
|
+
const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/)
|
|
123
|
+
if (!match) return null
|
|
124
|
+
|
|
125
|
+
const inner = match[1].trim()
|
|
126
|
+
if (!inner) return null // Empty locals is valid: locals: ()
|
|
127
|
+
|
|
128
|
+
const commaError = validateCommaUsage(inner)
|
|
129
|
+
if (commaError) return commaError
|
|
130
|
+
|
|
131
|
+
const params = splitByTopLevelComma(inner)
|
|
132
|
+
|
|
133
|
+
for (const param of params) {
|
|
134
|
+
const error = validateParameter(param)
|
|
135
|
+
if (error) return error
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
142
|
+
private seenStrictLocalsComment: boolean = false
|
|
143
|
+
private firstStrictLocalsLocation: { line: number; column: number } | null = null
|
|
144
|
+
|
|
145
|
+
visitERBContentNode(node: ERBContentNode): void {
|
|
146
|
+
const openingTag = node.tag_opening?.value
|
|
147
|
+
const content = node.content?.value
|
|
148
|
+
|
|
149
|
+
if (!content) return
|
|
150
|
+
|
|
151
|
+
const commentContent = this.extractCommentContent(openingTag, content, node)
|
|
152
|
+
if (!commentContent) return
|
|
153
|
+
|
|
154
|
+
const remainder = extractLocalsRemainder(commentContent)
|
|
155
|
+
if (!remainder || !hasLocalsLikeSyntax(remainder)) return
|
|
156
|
+
|
|
157
|
+
this.validateLocalsComment(commentContent, node)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private extractCommentContent(openingTag: string | undefined, content: string, node: ERBContentNode): string | null {
|
|
161
|
+
if (openingTag === "<%#") {
|
|
162
|
+
return extractERBCommentContent(content)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (openingTag === "<%" || openingTag === "<%-") {
|
|
166
|
+
const rubyComment = extractRubyCommentContent(content)
|
|
167
|
+
|
|
168
|
+
if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
|
|
169
|
+
this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private validateLocalsComment(commentContent: string, node: ERBContentNode): void {
|
|
177
|
+
this.checkPartialFile(node)
|
|
178
|
+
|
|
179
|
+
if (!hasBalancedParentheses(commentContent)) {
|
|
180
|
+
this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isValidStrictLocalsFormat(commentContent)) {
|
|
185
|
+
this.handleValidFormat(commentContent, node)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.handleInvalidFormat(commentContent, node)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private checkPartialFile(node: ERBContentNode): void {
|
|
193
|
+
const isPartial = isPartialFile(this.context.fileName)
|
|
194
|
+
|
|
195
|
+
if (isPartial === false) {
|
|
196
|
+
this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleValidFormat(commentContent: string, node: ERBContentNode): void {
|
|
201
|
+
if (this.seenStrictLocalsComment) {
|
|
202
|
+
this.addOffense(
|
|
203
|
+
`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`,
|
|
204
|
+
node.location
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.seenStrictLocalsComment = true
|
|
211
|
+
this.firstStrictLocalsLocation = {
|
|
212
|
+
line: node.location.start.line,
|
|
213
|
+
column: node.location.start.column
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/)
|
|
217
|
+
|
|
218
|
+
if (paramsMatch) {
|
|
219
|
+
const error = validateLocalsSignature(paramsMatch[1])
|
|
220
|
+
|
|
221
|
+
if (error) {
|
|
222
|
+
this.addOffense(error, node.location)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private handleInvalidFormat(commentContent: string, node: ERBContentNode): void {
|
|
228
|
+
if (detectLocalsWithoutColon(commentContent)) {
|
|
229
|
+
this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (detectSingularLocal(commentContent)) {
|
|
234
|
+
this.addOffense("Use `locals:` (plural), not `local:`.", node.location)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (detectMissingColonBeforeParens(commentContent)) {
|
|
239
|
+
this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (detectMissingParentheses(commentContent)) {
|
|
244
|
+
this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (detectEmptyLocalsWithoutParens(commentContent)) {
|
|
249
|
+
this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
|
|
258
|
+
name = "erb-strict-locals-comment-syntax"
|
|
259
|
+
|
|
260
|
+
get defaultConfig(): FullRuleConfig {
|
|
261
|
+
return {
|
|
262
|
+
enabled: true,
|
|
263
|
+
severity: "error"
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
|
|
268
|
+
const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context)
|
|
269
|
+
|
|
270
|
+
visitor.visit(result.value)
|
|
271
|
+
|
|
272
|
+
return visitor.offenses
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SourceRule } from "../types.js"
|
|
2
|
+
import { Location } from "@herb-tools/core"
|
|
3
|
+
import { BaseSourceRuleVisitor } from "./rule-utils.js"
|
|
4
|
+
|
|
5
|
+
import { isPartialFile } from "./file-utils.js"
|
|
6
|
+
|
|
7
|
+
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
8
|
+
|
|
9
|
+
function hasStrictLocals(source: string): boolean {
|
|
10
|
+
return source.includes("<%# locals:") || source.includes("<%#locals:")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
|
|
14
|
+
protected visitSource(source: string): void {
|
|
15
|
+
const isPartial = isPartialFile(this.context.fileName)
|
|
16
|
+
|
|
17
|
+
if (isPartial !== true) return
|
|
18
|
+
if (hasStrictLocals(source)) return
|
|
19
|
+
|
|
20
|
+
const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n")
|
|
21
|
+
const location = Location.from(1, 0, 1, firstLineLength)
|
|
22
|
+
|
|
23
|
+
this.addOffense(
|
|
24
|
+
"Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.",
|
|
25
|
+
location
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ERBStrictLocalsRequiredRule extends SourceRule {
|
|
31
|
+
static unsafeAutocorrectable = true
|
|
32
|
+
name = "erb-strict-locals-required"
|
|
33
|
+
|
|
34
|
+
get defaultConfig(): FullRuleConfig {
|
|
35
|
+
return {
|
|
36
|
+
enabled: false,
|
|
37
|
+
severity: "error",
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
check(source: string, context?: Partial<LintContext>): UnboundLintOffense[] {
|
|
42
|
+
const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context)
|
|
43
|
+
|
|
44
|
+
visitor.visit(source)
|
|
45
|
+
|
|
46
|
+
return visitor.offenses
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
autofix(_offense: LintOffense, source: string, _context?: Partial<LintContext>): string | null {
|
|
50
|
+
return `<%# locals: () %>\n\n${source}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File path and naming utilities for linter rules
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the basename (filename) from a file path
|
|
7
|
+
* Works with both forward slashes and backslashes
|
|
8
|
+
*/
|
|
9
|
+
export function getBasename(filePath: string): string {
|
|
10
|
+
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"))
|
|
11
|
+
|
|
12
|
+
return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks if a file is a Rails partial (filename starts with `_`)
|
|
17
|
+
* Returns null if fileName is undefined (unknown context)
|
|
18
|
+
*/
|
|
19
|
+
export function isPartialFile(fileName: string | undefined): boolean | null {
|
|
20
|
+
if (!fileName) return null
|
|
21
|
+
|
|
22
|
+
return getBasename(fileName).startsWith("_")
|
|
23
|
+
}
|
|
@@ -23,6 +23,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
23
23
|
if (!this.insideBody) return
|
|
24
24
|
if (!isHeadOnlyTag(tagName)) return
|
|
25
25
|
if (tagName === "title" && this.insideSVG) return
|
|
26
|
+
if (tagName === "style" && this.insideSVG) return
|
|
26
27
|
if (tagName === "meta" && this.hasItempropAttribute(node)) return
|
|
27
28
|
|
|
28
29
|
this.addOffense(
|
|
@@ -1,31 +1,29 @@
|
|
|
1
1
|
import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./rule-utils.js"
|
|
2
|
+
import { isHTMLOpenTagNode, isLiteralNode, isHTMLTextNode, isHTMLElementNode } from "@herb-tools/core"
|
|
2
3
|
|
|
3
4
|
import { ParserRule } from "../types.js"
|
|
4
5
|
|
|
5
6
|
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
6
|
-
import type { HTMLElementNode, HTMLOpenTagNode, ParseResult
|
|
7
|
+
import type { HTMLElementNode, HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
|
|
7
8
|
|
|
8
9
|
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
9
10
|
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
11
|
+
const tagName = getTagName(node.open_tag)?.toLowerCase()
|
|
12
|
+
if (tagName === "template") return
|
|
13
|
+
|
|
10
14
|
this.checkHeadingElement(node)
|
|
11
15
|
super.visitHTMLElementNode(node)
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
15
18
|
private checkHeadingElement(node: HTMLElementNode): void {
|
|
16
|
-
if (!node.open_tag
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
+
if (!node.open_tag) return
|
|
20
|
+
if (!isHTMLOpenTagNode(node.open_tag)) return
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!tagName) {
|
|
24
|
-
return
|
|
25
|
-
}
|
|
22
|
+
const tagName = getTagName(node.open_tag)
|
|
23
|
+
if (!tagName) return
|
|
26
24
|
|
|
27
25
|
const isStandardHeading = HEADING_TAGS.has(tagName)
|
|
28
|
-
const isAriaHeading = this.hasHeadingRole(
|
|
26
|
+
const isAriaHeading = this.hasHeadingRole(node.open_tag)
|
|
29
27
|
|
|
30
28
|
if (!isStandardHeading && !isAriaHeading) {
|
|
31
29
|
return
|
|
@@ -43,7 +41,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
43
41
|
}
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
|
|
47
44
|
private isEmptyHeading(node: HTMLElementNode): boolean {
|
|
48
45
|
if (!node.body || node.body.length === 0) {
|
|
49
46
|
return true
|
|
@@ -52,24 +49,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
52
49
|
let hasAccessibleContent = false
|
|
53
50
|
|
|
54
51
|
for (const child of node.body) {
|
|
55
|
-
if (child
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (literalNode.content.trim().length > 0) {
|
|
52
|
+
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
53
|
+
if (child.content.trim().length > 0) {
|
|
59
54
|
hasAccessibleContent = true
|
|
60
55
|
break
|
|
61
56
|
}
|
|
62
|
-
} else if (child
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (textNode.content.trim().length > 0) {
|
|
66
|
-
hasAccessibleContent = true
|
|
67
|
-
break
|
|
68
|
-
}
|
|
69
|
-
} else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
70
|
-
const elementNode = child as HTMLElementNode
|
|
71
|
-
|
|
72
|
-
if (this.isElementAccessible(elementNode)) {
|
|
57
|
+
} else if (isHTMLElementNode(child)) {
|
|
58
|
+
if (this.isElementAccessible(child)) {
|
|
73
59
|
hasAccessibleContent = true
|
|
74
60
|
break
|
|
75
61
|
}
|
|
@@ -95,12 +81,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
95
81
|
}
|
|
96
82
|
|
|
97
83
|
private isElementAccessible(node: HTMLElementNode): boolean {
|
|
98
|
-
if (!node.open_tag
|
|
99
|
-
|
|
100
|
-
}
|
|
84
|
+
if (!node.open_tag) return true
|
|
85
|
+
if (!isHTMLOpenTagNode(node.open_tag)) return true
|
|
101
86
|
|
|
102
|
-
const
|
|
103
|
-
const attributes = getAttributes(openTag)
|
|
87
|
+
const attributes = getAttributes(node.open_tag)
|
|
104
88
|
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden")
|
|
105
89
|
|
|
106
90
|
if (ariaHiddenAttribute) {
|
|
@@ -116,19 +100,12 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
116
100
|
}
|
|
117
101
|
|
|
118
102
|
for (const child of node.body) {
|
|
119
|
-
if (child
|
|
120
|
-
|
|
121
|
-
if (literalNode.content.trim().length > 0) {
|
|
122
|
-
return true
|
|
123
|
-
}
|
|
124
|
-
} else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
125
|
-
const textNode = child as HTMLTextNode
|
|
126
|
-
if (textNode.content.trim().length > 0) {
|
|
103
|
+
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
104
|
+
if (child.content.trim().length > 0) {
|
|
127
105
|
return true
|
|
128
106
|
}
|
|
129
|
-
} else if (child
|
|
130
|
-
|
|
131
|
-
if (this.isElementAccessible(elementNode)) {
|
|
107
|
+
} else if (isHTMLElementNode(child)) {
|
|
108
|
+
if (this.isElementAccessible(child)) {
|
|
132
109
|
return true
|
|
133
110
|
}
|
|
134
111
|
} else {
|
package/src/rules/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./rule-utils.js"
|
|
2
|
+
export * from "./file-utils.js"
|
|
3
|
+
export * from "./string-utils.js"
|
|
2
4
|
export * from "./herb-disable-comment-base.js"
|
|
3
5
|
|
|
4
6
|
export * from "./erb-comment-syntax.js"
|
|
@@ -12,6 +14,8 @@ export * from "./erb-prefer-image-tag-helper.js"
|
|
|
12
14
|
export * from "./erb-require-trailing-newline.js"
|
|
13
15
|
export * from "./erb-require-whitespace-inside-tags.js"
|
|
14
16
|
export * from "./erb-right-trim.js"
|
|
17
|
+
export * from "./erb-strict-locals-comment-syntax.js"
|
|
18
|
+
export * from "./erb-strict-locals-required.js"
|
|
15
19
|
|
|
16
20
|
export * from "./herb-disable-comment-valid-rule-name.js"
|
|
17
21
|
export * from "./herb-disable-comment-no-redundant-all.js"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if parentheses in a string are balanced
|
|
3
|
+
* Returns false if there are more closing parens than opening at any point
|
|
4
|
+
*/
|
|
5
|
+
export function hasBalancedParentheses(content: string): boolean {
|
|
6
|
+
let depth = 0
|
|
7
|
+
|
|
8
|
+
for (const char of content) {
|
|
9
|
+
if (char === "(") depth++
|
|
10
|
+
if (char === ")") depth--
|
|
11
|
+
if (depth < 0) return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return depth === 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Splits a string by commas at the top level only
|
|
19
|
+
* Respects nested parentheses, brackets, braces, and strings
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
|
|
23
|
+
* splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
|
|
24
|
+
* splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
|
|
25
|
+
*/
|
|
26
|
+
export function splitByTopLevelComma(str: string): string[] {
|
|
27
|
+
const result: string[] = []
|
|
28
|
+
|
|
29
|
+
let current = ""
|
|
30
|
+
let parenDepth = 0
|
|
31
|
+
let bracketDepth = 0
|
|
32
|
+
let braceDepth = 0
|
|
33
|
+
let inString = false
|
|
34
|
+
let stringChar = ""
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < str.length; i++) {
|
|
37
|
+
const char = str[i]
|
|
38
|
+
const previousChar = i > 0 ? str[i - 1] : ""
|
|
39
|
+
|
|
40
|
+
if ((char === '"' || char === "'") && previousChar !== "\\") {
|
|
41
|
+
if (!inString) {
|
|
42
|
+
inString = true
|
|
43
|
+
stringChar = char
|
|
44
|
+
} else if (char === stringChar) {
|
|
45
|
+
inString = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!inString) {
|
|
50
|
+
if (char === "(") parenDepth++
|
|
51
|
+
if (char === ")") parenDepth--
|
|
52
|
+
if (char === "[") bracketDepth++
|
|
53
|
+
if (char === "]") bracketDepth--
|
|
54
|
+
if (char === "{") braceDepth++
|
|
55
|
+
if (char === "}") braceDepth--
|
|
56
|
+
|
|
57
|
+
if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
|
|
58
|
+
result.push(current)
|
|
59
|
+
current = ""
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current += char
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (current) {
|
|
68
|
+
result.push(current)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
}
|
package/src/rules.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper
|
|
|
11
11
|
import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
|
|
12
12
|
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
|
|
13
13
|
import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
|
|
14
|
+
import { ERBStrictLocalsCommentSyntaxRule } from "./rules/erb-strict-locals-comment-syntax.js"
|
|
15
|
+
import { ERBStrictLocalsRequiredRule } from "./rules/erb-strict-locals-required.js"
|
|
14
16
|
|
|
15
17
|
import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"
|
|
16
18
|
import { HerbDisableCommentNoRedundantAllRule } from "./rules/herb-disable-comment-no-redundant-all.js"
|
|
@@ -67,6 +69,8 @@ export const rules: RuleClass[] = [
|
|
|
67
69
|
ERBRequireTrailingNewlineRule,
|
|
68
70
|
ERBRequireWhitespaceRule,
|
|
69
71
|
ERBRightTrimRule,
|
|
72
|
+
ERBStrictLocalsCommentSyntaxRule,
|
|
73
|
+
ERBStrictLocalsRequiredRule,
|
|
70
74
|
|
|
71
75
|
HerbDisableCommentValidRuleNameRule,
|
|
72
76
|
HerbDisableCommentNoRedundantAllRule,
|
package/src/types.ts
CHANGED
|
@@ -85,6 +85,8 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
|
|
|
85
85
|
static type = "parser" as const
|
|
86
86
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
87
87
|
static autocorrectable = false
|
|
88
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
89
|
+
static unsafeAutocorrectable = false
|
|
88
90
|
abstract name: string
|
|
89
91
|
|
|
90
92
|
get defaultConfig(): FullRuleConfig {
|
|
@@ -119,6 +121,8 @@ export abstract class LexerRule<TAutofixContext extends BaseAutofixContext = Bas
|
|
|
119
121
|
static type = "lexer" as const
|
|
120
122
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
121
123
|
static autocorrectable = false
|
|
124
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
125
|
+
static unsafeAutocorrectable = false
|
|
122
126
|
abstract name: string
|
|
123
127
|
|
|
124
128
|
get defaultConfig(): FullRuleConfig {
|
|
@@ -176,6 +180,8 @@ export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = Ba
|
|
|
176
180
|
static type = "source" as const
|
|
177
181
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
178
182
|
static autocorrectable = false
|
|
183
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
184
|
+
static unsafeAutocorrectable = false
|
|
179
185
|
abstract name: string
|
|
180
186
|
|
|
181
187
|
get defaultConfig(): FullRuleConfig {
|