@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/index.cjs
CHANGED
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
var core = require('@herb-tools/core');
|
|
4
4
|
|
|
5
|
+
class ParserRule {
|
|
6
|
+
static type = "parser";
|
|
7
|
+
}
|
|
8
|
+
class LexerRule {
|
|
9
|
+
static type = "lexer";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Default context object with all keys defined but set to undefined
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_LINT_CONTEXT = {
|
|
15
|
+
fileName: undefined
|
|
16
|
+
};
|
|
17
|
+
class SourceRule {
|
|
18
|
+
static type = "source";
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
/**
|
|
6
22
|
* Base visitor class that provides common functionality for rule visitors
|
|
7
23
|
*/
|
|
8
24
|
class BaseRuleVisitor extends core.Visitor {
|
|
9
25
|
offenses = [];
|
|
10
26
|
ruleName;
|
|
11
|
-
|
|
27
|
+
context;
|
|
28
|
+
constructor(ruleName, context) {
|
|
12
29
|
super();
|
|
13
30
|
this.ruleName = ruleName;
|
|
31
|
+
this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
|
|
14
32
|
}
|
|
15
33
|
/**
|
|
16
34
|
* Helper method to create a lint offense
|
|
@@ -148,6 +166,49 @@ const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
|
148
166
|
"noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
|
|
149
167
|
]);
|
|
150
168
|
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
169
|
+
/**
|
|
170
|
+
* SVG elements that use camelCase naming
|
|
171
|
+
*/
|
|
172
|
+
const SVG_CAMEL_CASE_ELEMENTS = new Set([
|
|
173
|
+
"animateMotion",
|
|
174
|
+
"animateTransform",
|
|
175
|
+
"clipPath",
|
|
176
|
+
"feBlend",
|
|
177
|
+
"feColorMatrix",
|
|
178
|
+
"feComponentTransfer",
|
|
179
|
+
"feComposite",
|
|
180
|
+
"feConvolveMatrix",
|
|
181
|
+
"feDiffuseLighting",
|
|
182
|
+
"feDisplacementMap",
|
|
183
|
+
"feDistantLight",
|
|
184
|
+
"feDropShadow",
|
|
185
|
+
"feFlood",
|
|
186
|
+
"feFuncA",
|
|
187
|
+
"feFuncB",
|
|
188
|
+
"feFuncG",
|
|
189
|
+
"feFuncR",
|
|
190
|
+
"feGaussianBlur",
|
|
191
|
+
"feImage",
|
|
192
|
+
"feMerge",
|
|
193
|
+
"feMergeNode",
|
|
194
|
+
"feMorphology",
|
|
195
|
+
"feOffset",
|
|
196
|
+
"fePointLight",
|
|
197
|
+
"feSpecularLighting",
|
|
198
|
+
"feSpotLight",
|
|
199
|
+
"feTile",
|
|
200
|
+
"feTurbulence",
|
|
201
|
+
"foreignObject",
|
|
202
|
+
"glyphRef",
|
|
203
|
+
"linearGradient",
|
|
204
|
+
"radialGradient",
|
|
205
|
+
"textPath"
|
|
206
|
+
]);
|
|
207
|
+
/**
|
|
208
|
+
* Mapping from lowercase SVG element names to their correct camelCase versions
|
|
209
|
+
* Generated dynamically from SVG_CAMEL_CASE_ELEMENTS
|
|
210
|
+
*/
|
|
211
|
+
const SVG_LOWERCASE_TO_CAMELCASE = new Map(Array.from(SVG_CAMEL_CASE_ELEMENTS).map(element => [element.toLowerCase(), element]));
|
|
151
212
|
const VALID_ARIA_ROLES = new Set([
|
|
152
213
|
"banner", "complementary", "contentinfo", "form", "main", "navigation", "region", "search",
|
|
153
214
|
"article", "cell", "columnheader", "definition", "directory", "document", "feed", "figure",
|
|
@@ -210,6 +271,19 @@ const ARIA_ATTRIBUTES = new Set([
|
|
|
210
271
|
'aria-valuenow',
|
|
211
272
|
'aria-valuetext',
|
|
212
273
|
]);
|
|
274
|
+
/**
|
|
275
|
+
* Helper function to create a location at the end of the source with a 1-character range
|
|
276
|
+
*/
|
|
277
|
+
function createEndOfFileLocation(source) {
|
|
278
|
+
const lines = source.split('\n');
|
|
279
|
+
const lastLineNumber = lines.length;
|
|
280
|
+
const lastLine = lines[lines.length - 1];
|
|
281
|
+
const lastColumnNumber = lastLine.length;
|
|
282
|
+
const startColumn = lastColumnNumber > 0 ? lastColumnNumber - 1 : 0;
|
|
283
|
+
const start = new core.Position(lastLineNumber, startColumn);
|
|
284
|
+
const end = new core.Position(lastLineNumber, lastColumnNumber);
|
|
285
|
+
return new core.Location(start, end);
|
|
286
|
+
}
|
|
213
287
|
/**
|
|
214
288
|
* Checks if an element is inline
|
|
215
289
|
*/
|
|
@@ -234,6 +308,9 @@ function isBooleanAttribute(attributeName) {
|
|
|
234
308
|
* and attribute iteration logic. Provides simplified interface with extracted attribute info.
|
|
235
309
|
*/
|
|
236
310
|
class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
311
|
+
constructor(ruleName, context) {
|
|
312
|
+
super(ruleName, context);
|
|
313
|
+
}
|
|
237
314
|
visitHTMLOpenTagNode(node) {
|
|
238
315
|
this.checkAttributesOnNode(node);
|
|
239
316
|
super.visitHTMLOpenTagNode(node);
|
|
@@ -263,6 +340,56 @@ function forEachAttribute(node, callback) {
|
|
|
263
340
|
}
|
|
264
341
|
}
|
|
265
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Base source visitor class that provides common functionality for source-based rule visitors
|
|
345
|
+
*/
|
|
346
|
+
class BaseSourceRuleVisitor {
|
|
347
|
+
offenses = [];
|
|
348
|
+
ruleName;
|
|
349
|
+
context;
|
|
350
|
+
constructor(ruleName, context) {
|
|
351
|
+
this.ruleName = ruleName;
|
|
352
|
+
this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Helper method to create a lint offense for source rules
|
|
356
|
+
*/
|
|
357
|
+
createOffense(message, location, severity = "error") {
|
|
358
|
+
return {
|
|
359
|
+
rule: this.ruleName, // Type assertion for compatibility
|
|
360
|
+
code: this.ruleName,
|
|
361
|
+
source: "Herb Linter",
|
|
362
|
+
message,
|
|
363
|
+
location,
|
|
364
|
+
severity,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Helper method to add an offense to the offenses array
|
|
369
|
+
*/
|
|
370
|
+
addOffense(message, location, severity = "error") {
|
|
371
|
+
this.offenses.push(this.createOffense(message, location, severity));
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Main entry point for source rule visitors
|
|
375
|
+
* @param source - The raw source code
|
|
376
|
+
*/
|
|
377
|
+
visit(source) {
|
|
378
|
+
this.visitSource(source);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Helper method to create a location for a specific position in the source
|
|
382
|
+
*/
|
|
383
|
+
createLocationAt(source, position) {
|
|
384
|
+
const beforePosition = source.substring(0, position);
|
|
385
|
+
const lines = beforePosition.split('\n');
|
|
386
|
+
const line = lines.length;
|
|
387
|
+
const column = lines[lines.length - 1].length + 1;
|
|
388
|
+
const start = new core.Position(line, column);
|
|
389
|
+
const end = new core.Position(line, column);
|
|
390
|
+
return new core.Location(start, end);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
266
393
|
|
|
267
394
|
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
268
395
|
visitERBContentNode(node) {
|
|
@@ -277,10 +404,10 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
|
277
404
|
this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
|
|
278
405
|
}
|
|
279
406
|
}
|
|
280
|
-
class ERBNoEmptyTagsRule {
|
|
407
|
+
class ERBNoEmptyTagsRule extends ParserRule {
|
|
281
408
|
name = "erb-no-empty-tags";
|
|
282
|
-
check(node) {
|
|
283
|
-
const visitor = new ERBNoEmptyTagsVisitor(this.name);
|
|
409
|
+
check(node, context) {
|
|
410
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
|
|
284
411
|
visitor.visit(node);
|
|
285
412
|
return visitor.offenses;
|
|
286
413
|
}
|
|
@@ -323,15 +450,126 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
|
323
450
|
return;
|
|
324
451
|
}
|
|
325
452
|
}
|
|
326
|
-
class ERBNoOutputControlFlowRule {
|
|
453
|
+
class ERBNoOutputControlFlowRule extends ParserRule {
|
|
327
454
|
name = "erb-no-output-control-flow";
|
|
328
|
-
check(node) {
|
|
329
|
-
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name);
|
|
455
|
+
check(node, context) {
|
|
456
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context);
|
|
457
|
+
visitor.visit(node);
|
|
458
|
+
return visitor.offenses;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
463
|
+
visitHTMLOpenTagNode(node) {
|
|
464
|
+
this.checkImgTag(node);
|
|
465
|
+
super.visitHTMLOpenTagNode(node);
|
|
466
|
+
}
|
|
467
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
468
|
+
this.checkImgTag(node);
|
|
469
|
+
super.visitHTMLSelfCloseTagNode(node);
|
|
470
|
+
}
|
|
471
|
+
checkImgTag(node) {
|
|
472
|
+
const tagName = getTagName(node);
|
|
473
|
+
if (tagName !== "img") {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const attributes = getAttributes(node);
|
|
477
|
+
const srcAttribute = findAttributeByName(attributes, "src");
|
|
478
|
+
if (!srcAttribute) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!srcAttribute.value) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const valueNode = srcAttribute.value;
|
|
485
|
+
const hasERBContent = this.containsERBContent(valueNode);
|
|
486
|
+
if (hasERBContent) {
|
|
487
|
+
const suggestedExpression = this.buildSuggestedExpression(valueNode);
|
|
488
|
+
this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
containsERBContent(valueNode) {
|
|
492
|
+
if (!valueNode.children)
|
|
493
|
+
return false;
|
|
494
|
+
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
|
|
495
|
+
}
|
|
496
|
+
buildSuggestedExpression(valueNode) {
|
|
497
|
+
if (!valueNode.children)
|
|
498
|
+
return "expression";
|
|
499
|
+
let hasText = false;
|
|
500
|
+
let hasERB = false;
|
|
501
|
+
for (const child of valueNode.children) {
|
|
502
|
+
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
503
|
+
hasERB = true;
|
|
504
|
+
}
|
|
505
|
+
else if (child.type === "AST_LITERAL_NODE") {
|
|
506
|
+
const literalNode = child;
|
|
507
|
+
if (literalNode.content && literalNode.content.trim()) {
|
|
508
|
+
hasText = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (hasText && hasERB) {
|
|
513
|
+
let result = '"';
|
|
514
|
+
for (const child of valueNode.children) {
|
|
515
|
+
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
516
|
+
const erbNode = child;
|
|
517
|
+
result += `#{${(erbNode.content?.value || "").trim()}}`;
|
|
518
|
+
}
|
|
519
|
+
else if (child.type === "AST_LITERAL_NODE") {
|
|
520
|
+
const literalNode = child;
|
|
521
|
+
result += literalNode.content || "";
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
result += '"';
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
527
|
+
if (hasERB && !hasText) {
|
|
528
|
+
const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
|
|
529
|
+
if (erbNodes.length === 1) {
|
|
530
|
+
return (erbNodes[0].content?.value || "").trim();
|
|
531
|
+
}
|
|
532
|
+
else if (erbNodes.length > 1) {
|
|
533
|
+
let result = '"';
|
|
534
|
+
for (const erbNode of erbNodes) {
|
|
535
|
+
result += `#{${(erbNode.content?.value || "").trim()}}`;
|
|
536
|
+
}
|
|
537
|
+
result += '"';
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return "expression";
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
class ERBPreferImageTagHelperRule extends ParserRule {
|
|
545
|
+
name = "erb-prefer-image-tag-helper";
|
|
546
|
+
check(node, context) {
|
|
547
|
+
const visitor = new ERBPreferImageTagHelperVisitor(this.name, context);
|
|
330
548
|
visitor.visit(node);
|
|
331
549
|
return visitor.offenses;
|
|
332
550
|
}
|
|
333
551
|
}
|
|
334
552
|
|
|
553
|
+
class ERBRequiresTrailingNewlineVisitor extends BaseSourceRuleVisitor {
|
|
554
|
+
visitSource(source) {
|
|
555
|
+
if (source.length === 0)
|
|
556
|
+
return;
|
|
557
|
+
if (source.endsWith('\n'))
|
|
558
|
+
return;
|
|
559
|
+
if (!this.context.fileName)
|
|
560
|
+
return;
|
|
561
|
+
this.addOffense("File must end with trailing newline", createEndOfFileLocation(source), "error");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
class ERBRequiresTrailingNewlineRule extends SourceRule {
|
|
565
|
+
name = "erb-requires-trailing-newline";
|
|
566
|
+
check(source, context) {
|
|
567
|
+
const visitor = new ERBRequiresTrailingNewlineVisitor(this.name, context);
|
|
568
|
+
visitor.visit(source);
|
|
569
|
+
return visitor.offenses;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
335
573
|
class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
336
574
|
visitChildNodes(node) {
|
|
337
575
|
this.checkWhitespace(node);
|
|
@@ -348,8 +586,24 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
348
586
|
return;
|
|
349
587
|
}
|
|
350
588
|
const value = content.value;
|
|
351
|
-
|
|
352
|
-
|
|
589
|
+
if (openTag.value === "<%#") {
|
|
590
|
+
this.checkCommentTagWhitespace(openTag, closeTag, value);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
this.checkOpenTagWhitespace(openTag, value);
|
|
594
|
+
this.checkCloseTagWhitespace(closeTag, value);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
checkCommentTagWhitespace(openTag, closeTag, content) {
|
|
598
|
+
if (!content.startsWith(" ") && !content.startsWith("\n") && !content.startsWith("=")) {
|
|
599
|
+
this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, "error");
|
|
600
|
+
}
|
|
601
|
+
else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
|
|
602
|
+
this.addOffense(`Add whitespace after \`<%#=\`.`, openTag.location, "error");
|
|
603
|
+
}
|
|
604
|
+
if (!content.endsWith(" ") && !content.endsWith("\n")) {
|
|
605
|
+
this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
|
|
606
|
+
}
|
|
353
607
|
}
|
|
354
608
|
checkOpenTagWhitespace(openTag, content) {
|
|
355
609
|
if (content.startsWith(" ") || content.startsWith("\n")) {
|
|
@@ -364,10 +618,10 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
364
618
|
this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
|
|
365
619
|
}
|
|
366
620
|
}
|
|
367
|
-
class ERBRequireWhitespaceRule {
|
|
621
|
+
class ERBRequireWhitespaceRule extends ParserRule {
|
|
368
622
|
name = "erb-require-whitespace-inside-tags";
|
|
369
|
-
check(node) {
|
|
370
|
-
const visitor = new RequireWhitespaceInsideTags(this.name);
|
|
623
|
+
check(node, context) {
|
|
624
|
+
const visitor = new RequireWhitespaceInsideTags(this.name, context);
|
|
371
625
|
visitor.visit(node);
|
|
372
626
|
return visitor.offenses;
|
|
373
627
|
}
|
|
@@ -388,10 +642,10 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
|
|
|
388
642
|
}
|
|
389
643
|
}
|
|
390
644
|
}
|
|
391
|
-
class HTMLAnchorRequireHrefRule {
|
|
645
|
+
class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
392
646
|
name = "html-anchor-require-href";
|
|
393
|
-
check(node) {
|
|
394
|
-
const visitor = new AnchorRechireHrefVisitor(this.name);
|
|
647
|
+
check(node, context) {
|
|
648
|
+
const visitor = new AnchorRechireHrefVisitor(this.name, context);
|
|
395
649
|
visitor.visit(node);
|
|
396
650
|
return visitor.offenses;
|
|
397
651
|
}
|
|
@@ -411,10 +665,35 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
|
|
|
411
665
|
}
|
|
412
666
|
}
|
|
413
667
|
}
|
|
414
|
-
class HTMLAriaAttributeMustBeValid {
|
|
668
|
+
class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
415
669
|
name = "html-aria-attribute-must-be-valid";
|
|
416
|
-
check(node) {
|
|
417
|
-
const visitor = new AriaAttributeMustBeValid(this.name);
|
|
670
|
+
check(node, context) {
|
|
671
|
+
const visitor = new AriaAttributeMustBeValid(this.name, context);
|
|
672
|
+
visitor.visit(node);
|
|
673
|
+
return visitor.offenses;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
678
|
+
checkAttribute(attributeName, attributeValue, attributeNode, _parentNode) {
|
|
679
|
+
if (attributeName !== "aria-level")
|
|
680
|
+
return;
|
|
681
|
+
if (attributeValue !== null && attributeValue.includes("<%"))
|
|
682
|
+
return;
|
|
683
|
+
if (attributeValue === null || attributeValue === "") {
|
|
684
|
+
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const number = parseInt(attributeValue);
|
|
688
|
+
if (isNaN(number) || number < 1 || number > 6 || attributeValue !== number.toString()) {
|
|
689
|
+
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${attributeValue}\`.`, attributeNode.location);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
694
|
+
name = "html-aria-level-must-be-valid";
|
|
695
|
+
check(node, context) {
|
|
696
|
+
const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context);
|
|
418
697
|
visitor.visit(node);
|
|
419
698
|
return visitor.offenses;
|
|
420
699
|
}
|
|
@@ -436,10 +715,10 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
|
436
715
|
}
|
|
437
716
|
}
|
|
438
717
|
}
|
|
439
|
-
class HTMLAriaRoleHeadingRequiresLevelRule {
|
|
718
|
+
class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
440
719
|
name = "html-aria-role-heading-requires-level";
|
|
441
|
-
check(node) {
|
|
442
|
-
const visitor = new AriaRoleHeadingRequiresLevel(this.name);
|
|
720
|
+
check(node, context) {
|
|
721
|
+
const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
|
|
443
722
|
visitor.visit(node);
|
|
444
723
|
return visitor.offenses;
|
|
445
724
|
}
|
|
@@ -456,10 +735,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
|
456
735
|
this.addOffense(`The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`, attributeNode.location, "error");
|
|
457
736
|
}
|
|
458
737
|
}
|
|
459
|
-
class HTMLAriaRoleMustBeValidRule {
|
|
738
|
+
class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
460
739
|
name = "html-aria-role-must-be-valid";
|
|
461
|
-
check(node) {
|
|
462
|
-
const visitor = new AriaRoleMustBeValid(this.name);
|
|
740
|
+
check(node, context) {
|
|
741
|
+
const visitor = new AriaRoleMustBeValid(this.name, context);
|
|
463
742
|
visitor.visit(node);
|
|
464
743
|
return visitor.offenses;
|
|
465
744
|
}
|
|
@@ -476,10 +755,10 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
|
476
755
|
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
|
|
477
756
|
}
|
|
478
757
|
}
|
|
479
|
-
class HTMLAttributeDoubleQuotesRule {
|
|
758
|
+
class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
480
759
|
name = "html-attribute-double-quotes";
|
|
481
|
-
check(node) {
|
|
482
|
-
const visitor = new AttributeDoubleQuotesVisitor(this.name);
|
|
760
|
+
check(node, context) {
|
|
761
|
+
const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
|
|
483
762
|
visitor.visit(node);
|
|
484
763
|
return visitor.offenses;
|
|
485
764
|
}
|
|
@@ -497,10 +776,10 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
|
497
776
|
`Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
|
|
498
777
|
}
|
|
499
778
|
}
|
|
500
|
-
class HTMLAttributeValuesRequireQuotesRule {
|
|
779
|
+
class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
501
780
|
name = "html-attribute-values-require-quotes";
|
|
502
|
-
check(node) {
|
|
503
|
-
const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
|
|
781
|
+
check(node, context) {
|
|
782
|
+
const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
|
|
504
783
|
visitor.visit(node);
|
|
505
784
|
return visitor.offenses;
|
|
506
785
|
}
|
|
@@ -515,10 +794,10 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
|
515
794
|
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
|
|
516
795
|
}
|
|
517
796
|
}
|
|
518
|
-
class HTMLBooleanAttributesNoValueRule {
|
|
797
|
+
class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
519
798
|
name = "html-boolean-attributes-no-value";
|
|
520
|
-
check(node) {
|
|
521
|
-
const visitor = new BooleanAttributesNoValueVisitor(this.name);
|
|
799
|
+
check(node, context) {
|
|
800
|
+
const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
|
|
522
801
|
visitor.visit(node);
|
|
523
802
|
return visitor.offenses;
|
|
524
803
|
}
|
|
@@ -543,10 +822,10 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
|
543
822
|
}
|
|
544
823
|
}
|
|
545
824
|
}
|
|
546
|
-
class HTMLImgRequireAltRule {
|
|
825
|
+
class HTMLImgRequireAltRule extends ParserRule {
|
|
547
826
|
name = "html-img-require-alt";
|
|
548
|
-
check(node) {
|
|
549
|
-
const visitor = new ImgRequireAltVisitor(this.name);
|
|
827
|
+
check(node, context) {
|
|
828
|
+
const visitor = new ImgRequireAltVisitor(this.name, context);
|
|
550
829
|
visitor.visit(node);
|
|
551
830
|
return visitor.offenses;
|
|
552
831
|
}
|
|
@@ -585,10 +864,10 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
|
|
|
585
864
|
}
|
|
586
865
|
}
|
|
587
866
|
}
|
|
588
|
-
class HTMLNoDuplicateAttributesRule {
|
|
867
|
+
class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
589
868
|
name = "html-no-duplicate-attributes";
|
|
590
|
-
check(node) {
|
|
591
|
-
const visitor = new NoDuplicateAttributesVisitor(this.name);
|
|
869
|
+
check(node, context) {
|
|
870
|
+
const visitor = new NoDuplicateAttributesVisitor(this.name, context);
|
|
592
871
|
visitor.visit(node);
|
|
593
872
|
return visitor.offenses;
|
|
594
873
|
}
|
|
@@ -609,10 +888,10 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
|
|
|
609
888
|
this.documentIds.add(id);
|
|
610
889
|
}
|
|
611
890
|
}
|
|
612
|
-
class HTMLNoDuplicateIdsRule {
|
|
891
|
+
class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
613
892
|
name = "html-no-duplicate-ids";
|
|
614
|
-
check(node) {
|
|
615
|
-
const visitor = new NoDuplicateIdsVisitor(this.name);
|
|
893
|
+
check(node, context) {
|
|
894
|
+
const visitor = new NoDuplicateIdsVisitor(this.name, context);
|
|
616
895
|
visitor.visit(node);
|
|
617
896
|
return visitor.offenses;
|
|
618
897
|
}
|
|
@@ -756,10 +1035,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
756
1035
|
return false;
|
|
757
1036
|
}
|
|
758
1037
|
}
|
|
759
|
-
class HTMLNoEmptyHeadingsRule {
|
|
1038
|
+
class HTMLNoEmptyHeadingsRule extends ParserRule {
|
|
760
1039
|
name = "html-no-empty-headings";
|
|
761
|
-
check(node) {
|
|
762
|
-
const visitor = new NoEmptyHeadingsVisitor(this.name);
|
|
1040
|
+
check(node, context) {
|
|
1041
|
+
const visitor = new NoEmptyHeadingsVisitor(this.name, context);
|
|
763
1042
|
visitor.visit(node);
|
|
764
1043
|
return visitor.offenses;
|
|
765
1044
|
}
|
|
@@ -800,33 +1079,98 @@ class NestedLinkVisitor extends BaseRuleVisitor {
|
|
|
800
1079
|
super.visitHTMLOpenTagNode(node);
|
|
801
1080
|
}
|
|
802
1081
|
}
|
|
803
|
-
class HTMLNoNestedLinksRule {
|
|
1082
|
+
class HTMLNoNestedLinksRule extends ParserRule {
|
|
804
1083
|
name = "html-no-nested-links";
|
|
805
|
-
check(node) {
|
|
806
|
-
const visitor = new NestedLinkVisitor(this.name);
|
|
1084
|
+
check(node, context) {
|
|
1085
|
+
const visitor = new NestedLinkVisitor(this.name, context);
|
|
807
1086
|
visitor.visit(node);
|
|
808
1087
|
return visitor.offenses;
|
|
809
1088
|
}
|
|
810
1089
|
}
|
|
811
1090
|
|
|
812
1091
|
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
813
|
-
|
|
814
|
-
|
|
1092
|
+
visitHTMLElementNode(node) {
|
|
1093
|
+
const tagName = node.tag_name?.value;
|
|
1094
|
+
if (node.open_tag) {
|
|
1095
|
+
this.checkTagName(node.open_tag);
|
|
1096
|
+
}
|
|
1097
|
+
if (tagName && ["svg"].includes(tagName.toLowerCase())) {
|
|
1098
|
+
if (node.close_tag) {
|
|
1099
|
+
this.checkTagName(node.close_tag);
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
815
1103
|
this.visitChildNodes(node);
|
|
1104
|
+
if (node.close_tag) {
|
|
1105
|
+
this.checkTagName(node.close_tag);
|
|
1106
|
+
}
|
|
816
1107
|
}
|
|
817
|
-
|
|
1108
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
818
1109
|
this.checkTagName(node);
|
|
819
1110
|
this.visitChildNodes(node);
|
|
820
1111
|
}
|
|
1112
|
+
checkTagName(node) {
|
|
1113
|
+
const tagName = node.tag_name?.value;
|
|
1114
|
+
if (!tagName)
|
|
1115
|
+
return;
|
|
1116
|
+
const lowercaseTagName = tagName.toLowerCase();
|
|
1117
|
+
if (tagName !== lowercaseTagName) {
|
|
1118
|
+
let type = node.type;
|
|
1119
|
+
if (node.type == "AST_HTML_OPEN_TAG_NODE")
|
|
1120
|
+
type = "Opening";
|
|
1121
|
+
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1122
|
+
type = "Closing";
|
|
1123
|
+
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1124
|
+
type = "Self-closing";
|
|
1125
|
+
this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`, node.tag_name.location, "error");
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
class HTMLTagNameLowercaseRule extends ParserRule {
|
|
1130
|
+
name = "html-tag-name-lowercase";
|
|
1131
|
+
check(node, context) {
|
|
1132
|
+
const visitor = new TagNameLowercaseVisitor(this.name, context);
|
|
1133
|
+
visitor.visit(node);
|
|
1134
|
+
return visitor.offenses;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
1139
|
+
insideSVG = false;
|
|
1140
|
+
visitHTMLElementNode(node) {
|
|
1141
|
+
const tagName = node.tag_name?.value;
|
|
1142
|
+
if (tagName && ["svg"].includes(tagName.toLowerCase())) {
|
|
1143
|
+
const wasInsideSVG = this.insideSVG;
|
|
1144
|
+
this.insideSVG = true;
|
|
1145
|
+
this.visitChildNodes(node);
|
|
1146
|
+
this.insideSVG = wasInsideSVG;
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (this.insideSVG) {
|
|
1150
|
+
if (node.open_tag) {
|
|
1151
|
+
this.checkTagName(node.open_tag);
|
|
1152
|
+
}
|
|
1153
|
+
if (node.close_tag) {
|
|
1154
|
+
this.checkTagName(node.close_tag);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
this.visitChildNodes(node);
|
|
1158
|
+
}
|
|
821
1159
|
visitHTMLSelfCloseTagNode(node) {
|
|
822
|
-
this.
|
|
1160
|
+
if (this.insideSVG) {
|
|
1161
|
+
this.checkTagName(node);
|
|
1162
|
+
}
|
|
823
1163
|
this.visitChildNodes(node);
|
|
824
1164
|
}
|
|
825
1165
|
checkTagName(node) {
|
|
826
1166
|
const tagName = node.tag_name?.value;
|
|
827
1167
|
if (!tagName)
|
|
828
1168
|
return;
|
|
829
|
-
if (
|
|
1169
|
+
if (SVG_CAMEL_CASE_ELEMENTS.has(tagName))
|
|
1170
|
+
return;
|
|
1171
|
+
const lowercaseTagName = tagName.toLowerCase();
|
|
1172
|
+
const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
|
|
1173
|
+
if (correctCamelCase && tagName !== correctCamelCase) {
|
|
830
1174
|
let type = node.type;
|
|
831
1175
|
if (node.type == "AST_HTML_OPEN_TAG_NODE")
|
|
832
1176
|
type = "Opening";
|
|
@@ -834,14 +1178,14 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
|
834
1178
|
type = "Closing";
|
|
835
1179
|
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
836
1180
|
type = "Self-closing";
|
|
837
|
-
this.addOffense(`${type} tag name \`${tagName}\` should
|
|
1181
|
+
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
838
1182
|
}
|
|
839
1183
|
}
|
|
840
1184
|
}
|
|
841
|
-
class
|
|
842
|
-
name = "
|
|
843
|
-
check(node) {
|
|
844
|
-
const visitor = new
|
|
1185
|
+
class SVGTagNameCapitalizationRule extends ParserRule {
|
|
1186
|
+
name = "svg-tag-name-capitalization";
|
|
1187
|
+
check(node, context) {
|
|
1188
|
+
const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
|
|
845
1189
|
visitor.visit(node);
|
|
846
1190
|
return visitor.offenses;
|
|
847
1191
|
}
|
|
@@ -850,9 +1194,12 @@ class HTMLTagNameLowercaseRule {
|
|
|
850
1194
|
const defaultRules = [
|
|
851
1195
|
ERBNoEmptyTagsRule,
|
|
852
1196
|
ERBNoOutputControlFlowRule,
|
|
1197
|
+
ERBPreferImageTagHelperRule,
|
|
1198
|
+
ERBRequiresTrailingNewlineRule,
|
|
853
1199
|
ERBRequireWhitespaceRule,
|
|
854
1200
|
HTMLAnchorRequireHrefRule,
|
|
855
1201
|
HTMLAriaAttributeMustBeValid,
|
|
1202
|
+
HTMLAriaLevelMustBeValidRule,
|
|
856
1203
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
857
1204
|
HTMLAriaRoleMustBeValidRule,
|
|
858
1205
|
HTMLAttributeDoubleQuotesRule,
|
|
@@ -865,16 +1212,20 @@ const defaultRules = [
|
|
|
865
1212
|
HTMLNoEmptyHeadingsRule,
|
|
866
1213
|
HTMLNoNestedLinksRule,
|
|
867
1214
|
HTMLTagNameLowercaseRule,
|
|
1215
|
+
SVGTagNameCapitalizationRule,
|
|
868
1216
|
];
|
|
869
1217
|
|
|
870
1218
|
class Linter {
|
|
871
1219
|
rules;
|
|
1220
|
+
herb;
|
|
872
1221
|
offenses;
|
|
873
1222
|
/**
|
|
874
1223
|
* Creates a new Linter instance.
|
|
875
|
-
* @param
|
|
1224
|
+
* @param herb - The Herb backend instance for parsing and lexing
|
|
1225
|
+
* @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
|
|
876
1226
|
*/
|
|
877
|
-
constructor(rules) {
|
|
1227
|
+
constructor(herb, rules) {
|
|
1228
|
+
this.herb = herb;
|
|
878
1229
|
this.rules = rules !== undefined ? rules : this.getDefaultRules();
|
|
879
1230
|
this.offenses = [];
|
|
880
1231
|
}
|
|
@@ -888,11 +1239,39 @@ class Linter {
|
|
|
888
1239
|
getRuleCount() {
|
|
889
1240
|
return this.rules.length;
|
|
890
1241
|
}
|
|
891
|
-
|
|
1242
|
+
/**
|
|
1243
|
+
* Type guard to check if a rule is a LexerRule
|
|
1244
|
+
*/
|
|
1245
|
+
isLexerRule(rule) {
|
|
1246
|
+
return rule.constructor.type === "lexer";
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Type guard to check if a rule is a SourceRule
|
|
1250
|
+
*/
|
|
1251
|
+
isSourceRule(rule) {
|
|
1252
|
+
return rule.constructor.type === "source";
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Lint source code using Parser/AST, Lexer, and Source rules.
|
|
1256
|
+
* @param source - The source code to lint
|
|
1257
|
+
* @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
|
|
1258
|
+
*/
|
|
1259
|
+
lint(source, context) {
|
|
892
1260
|
this.offenses = [];
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1261
|
+
const parseResult = this.herb.parse(source);
|
|
1262
|
+
const lexResult = this.herb.lex(source);
|
|
1263
|
+
for (const RuleClass of this.rules) {
|
|
1264
|
+
const rule = new RuleClass();
|
|
1265
|
+
let ruleOffenses;
|
|
1266
|
+
if (this.isLexerRule(rule)) {
|
|
1267
|
+
ruleOffenses = rule.check(lexResult, context);
|
|
1268
|
+
}
|
|
1269
|
+
else if (this.isSourceRule(rule)) {
|
|
1270
|
+
ruleOffenses = rule.check(source, context);
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
ruleOffenses = rule.check(parseResult.value, context);
|
|
1274
|
+
}
|
|
896
1275
|
this.offenses.push(...ruleOffenses);
|
|
897
1276
|
}
|
|
898
1277
|
const errors = this.offenses.filter(offense => offense.severity === "error").length;
|
|
@@ -954,18 +1333,22 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
954
1333
|
this.visitBlockElement(node);
|
|
955
1334
|
}
|
|
956
1335
|
}
|
|
957
|
-
class HTMLNoBlockInsideInlineRule {
|
|
1336
|
+
class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
958
1337
|
name = "html-no-block-inside-inline";
|
|
959
|
-
check(node) {
|
|
960
|
-
const visitor = new BlockInsideInlineVisitor(this.name);
|
|
1338
|
+
check(node, context) {
|
|
1339
|
+
const visitor = new BlockInsideInlineVisitor(this.name, context);
|
|
961
1340
|
visitor.visit(node);
|
|
962
1341
|
return visitor.offenses;
|
|
963
1342
|
}
|
|
964
1343
|
}
|
|
965
1344
|
|
|
1345
|
+
exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
|
|
966
1346
|
exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
|
|
967
1347
|
exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
|
|
1348
|
+
exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
|
|
1349
|
+
exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
|
|
968
1350
|
exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
|
|
1351
|
+
exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
|
|
969
1352
|
exports.HTMLAriaRoleHeadingRequiresLevelRule = HTMLAriaRoleHeadingRequiresLevelRule;
|
|
970
1353
|
exports.HTMLAriaRoleMustBeValidRule = HTMLAriaRoleMustBeValidRule;
|
|
971
1354
|
exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
|
|
@@ -978,5 +1361,9 @@ exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
|
|
|
978
1361
|
exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
|
|
979
1362
|
exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
|
|
980
1363
|
exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
|
|
1364
|
+
exports.LexerRule = LexerRule;
|
|
981
1365
|
exports.Linter = Linter;
|
|
1366
|
+
exports.ParserRule = ParserRule;
|
|
1367
|
+
exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
|
|
1368
|
+
exports.SourceRule = SourceRule;
|
|
982
1369
|
//# sourceMappingURL=index.cjs.map
|