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