@herb-tools/linter 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -16
- package/dist/herb-lint.js +1684 -295
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1226 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1188 -160
- package/dist/index.js.map +1 -1
- package/dist/package.json +11 -4
- package/dist/src/cli/argument-parser.js +11 -6
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +5 -6
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +3 -5
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
- package/dist/src/cli/index.js +1 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/output-manager.js +23 -5
- package/dist/src/cli/output-manager.js.map +1 -1
- package/dist/src/cli/summary-reporter.js +2 -11
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +88 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +8 -4
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +134 -9
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-attributes.js +56 -0
- package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
- package/dist/src/rules/html-no-positive-tab-index.js +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
- package/dist/src/rules/html-no-self-closing.js +12 -5
- package/dist/src/rules/html-no-self-closing.js.map +1 -1
- package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
- package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
- package/dist/src/rules/index.js +3 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +80 -7
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +2 -1
- package/dist/types/cli/file-processor.d.ts +6 -1
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/output-manager.d.ts +1 -0
- package/dist/types/cli.d.ts +20 -5
- package/dist/types/linter.d.ts +7 -7
- package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/rule-utils.d.ts +46 -5
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -1
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/cli/output-manager.d.ts +1 -0
- package/dist/types/src/cli.d.ts +20 -5
- package/dist/types/src/linter.d.ts +7 -7
- package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/src/rules/index.d.ts +3 -0
- package/dist/types/src/rules/rule-utils.d.ts +46 -5
- package/docs/rules/README.md +2 -0
- package/docs/rules/html-img-require-alt.md +0 -2
- package/docs/rules/html-no-empty-attributes.md +77 -0
- package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
- package/package.json +11 -4
- package/src/cli/argument-parser.ts +15 -7
- package/src/cli/file-processor.ts +11 -7
- package/src/cli/formatters/detailed-formatter.ts +5 -7
- package/src/cli/formatters/github-actions-formatter.ts +64 -11
- package/src/cli/index.ts +2 -0
- package/src/cli/output-manager.ts +27 -5
- package/src/cli/summary-reporter.ts +3 -11
- package/src/cli.ts +125 -20
- package/src/default-rules.ts +8 -4
- package/src/linter.ts +6 -6
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
- package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
- package/src/rules/html-attribute-double-quotes.ts +1 -1
- package/src/rules/html-boolean-attributes-no-value.ts +9 -11
- package/src/rules/html-no-duplicate-ids.ts +188 -14
- package/src/rules/html-no-empty-attributes.ts +75 -0
- package/src/rules/html-no-positive-tab-index.ts +1 -1
- package/src/rules/html-no-self-closing.ts +13 -8
- package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
- package/src/rules/html-tag-name-lowercase.ts +1 -1
- package/src/rules/index.ts +3 -0
- package/src/rules/rule-utils.ts +110 -9
- package/src/rules/svg-tag-name-capitalization.ts +2 -2
package/dist/index.cjs
CHANGED
|
@@ -18,6 +18,11 @@ class SourceRule {
|
|
|
18
18
|
static type = "source";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
exports.ControlFlowType = void 0;
|
|
22
|
+
(function (ControlFlowType) {
|
|
23
|
+
ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
|
|
24
|
+
ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
|
|
25
|
+
})(exports.ControlFlowType || (exports.ControlFlowType = {}));
|
|
21
26
|
/**
|
|
22
27
|
* Base visitor class that provides common functionality for rule visitors
|
|
23
28
|
*/
|
|
@@ -50,6 +55,70 @@ class BaseRuleVisitor extends core.Visitor {
|
|
|
50
55
|
this.offenses.push(this.createOffense(message, location, severity));
|
|
51
56
|
}
|
|
52
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Mixin that adds control flow tracking capabilities to rule visitors
|
|
60
|
+
* This allows rules to track state across different control flow structures
|
|
61
|
+
* like if/else branches, loops, etc.
|
|
62
|
+
*
|
|
63
|
+
* @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
|
|
64
|
+
* @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
|
|
65
|
+
*/
|
|
66
|
+
class ControlFlowTrackingVisitor extends BaseRuleVisitor {
|
|
67
|
+
isInControlFlow = false;
|
|
68
|
+
currentControlFlowType = null;
|
|
69
|
+
/**
|
|
70
|
+
* Handle visiting a control flow node with proper scope management
|
|
71
|
+
*/
|
|
72
|
+
handleControlFlowNode(node, controlFlowType, visitChildren) {
|
|
73
|
+
const wasInControlFlow = this.isInControlFlow;
|
|
74
|
+
const previousControlFlowType = this.currentControlFlowType;
|
|
75
|
+
this.isInControlFlow = true;
|
|
76
|
+
this.currentControlFlowType = controlFlowType;
|
|
77
|
+
const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow);
|
|
78
|
+
visitChildren();
|
|
79
|
+
this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore);
|
|
80
|
+
this.isInControlFlow = wasInControlFlow;
|
|
81
|
+
this.currentControlFlowType = previousControlFlowType;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Handle visiting a branch node (like else, when) with proper scope management
|
|
85
|
+
*/
|
|
86
|
+
startNewBranch(visitChildren) {
|
|
87
|
+
const stateToRestore = this.onEnterBranch();
|
|
88
|
+
visitChildren();
|
|
89
|
+
this.onExitBranch(stateToRestore);
|
|
90
|
+
}
|
|
91
|
+
visitERBIfNode(node) {
|
|
92
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
|
|
93
|
+
}
|
|
94
|
+
visitERBUnlessNode(node) {
|
|
95
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
|
|
96
|
+
}
|
|
97
|
+
visitERBCaseNode(node) {
|
|
98
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
|
|
99
|
+
}
|
|
100
|
+
visitERBCaseMatchNode(node) {
|
|
101
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
|
|
102
|
+
}
|
|
103
|
+
visitERBWhileNode(node) {
|
|
104
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
|
|
105
|
+
}
|
|
106
|
+
visitERBForNode(node) {
|
|
107
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBForNode(node));
|
|
108
|
+
}
|
|
109
|
+
visitERBUntilNode(node) {
|
|
110
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
|
|
111
|
+
}
|
|
112
|
+
visitERBBlockNode(node) {
|
|
113
|
+
this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
|
|
114
|
+
}
|
|
115
|
+
visitERBElseNode(node) {
|
|
116
|
+
this.startNewBranch(() => super.visitERBElseNode(node));
|
|
117
|
+
}
|
|
118
|
+
visitERBWhenNode(node) {
|
|
119
|
+
this.startNewBranch(() => super.visitERBWhenNode(node));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
53
122
|
/**
|
|
54
123
|
* Gets attributes from an HTMLOpenTagNode
|
|
55
124
|
*/
|
|
@@ -66,10 +135,12 @@ function getTagName(node) {
|
|
|
66
135
|
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
67
136
|
* Returns null if the attribute name contains dynamic content (ERB)
|
|
68
137
|
*/
|
|
69
|
-
function getAttributeName(attributeNode) {
|
|
138
|
+
function getAttributeName(attributeNode, lowercase = true) {
|
|
70
139
|
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
71
140
|
const nameNode = attributeNode.name;
|
|
72
141
|
const staticName = core.getStaticAttributeName(nameNode);
|
|
142
|
+
if (!lowercase)
|
|
143
|
+
return staticName;
|
|
73
144
|
return staticName ? staticName.toLowerCase() : null;
|
|
74
145
|
}
|
|
75
146
|
return null;
|
|
@@ -104,6 +175,15 @@ function hasStaticAttributeValue(attributeNode) {
|
|
|
104
175
|
return false;
|
|
105
176
|
return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
|
|
106
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Checks if an attribute value contains dynamic content (ERB)
|
|
180
|
+
*/
|
|
181
|
+
function hasDynamicAttributeValue(attributeNode) {
|
|
182
|
+
const valueNode = attributeNode.value;
|
|
183
|
+
if (!valueNode?.children)
|
|
184
|
+
return false;
|
|
185
|
+
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
|
|
186
|
+
}
|
|
107
187
|
/**
|
|
108
188
|
* Gets the static string value of an attribute (returns null if it contains ERB)
|
|
109
189
|
*/
|
|
@@ -124,6 +204,21 @@ function getAttributeValueNodes(attributeNode) {
|
|
|
124
204
|
const valueNode = attributeNode.value;
|
|
125
205
|
return valueNode?.children || [];
|
|
126
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Checks if an attribute value contains any static content (for validation purposes)
|
|
209
|
+
*/
|
|
210
|
+
function hasStaticAttributeValueContent(attributeNode) {
|
|
211
|
+
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
212
|
+
return core.hasStaticContent(valueNodes);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Gets the static content of an attribute value (all literal parts combined)
|
|
216
|
+
* Returns the concatenated literal content, or null if no literal nodes exist
|
|
217
|
+
*/
|
|
218
|
+
function getStaticAttributeValueContent(attributeNode) {
|
|
219
|
+
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
220
|
+
return core.getStaticContentFromNodes(valueNodes);
|
|
221
|
+
}
|
|
127
222
|
/**
|
|
128
223
|
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
129
224
|
*/
|
|
@@ -398,6 +493,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
398
493
|
checkAttributesOnNode(node) {
|
|
399
494
|
forEachAttribute(node, (attributeNode) => {
|
|
400
495
|
const staticAttributeName = getAttributeName(attributeNode);
|
|
496
|
+
const originalAttributeName = getAttributeName(attributeNode, false) || "";
|
|
401
497
|
const isDynamicName = hasDynamicAttributeName(attributeNode);
|
|
402
498
|
const staticAttributeValue = getStaticAttributeValue(attributeNode);
|
|
403
499
|
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
@@ -408,16 +504,17 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
408
504
|
attributeName: staticAttributeName,
|
|
409
505
|
attributeValue: staticAttributeValue,
|
|
410
506
|
attributeNode,
|
|
507
|
+
originalAttributeName,
|
|
411
508
|
parentNode: node
|
|
412
509
|
});
|
|
413
510
|
}
|
|
414
511
|
else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
|
|
415
512
|
const validatableContent = core.getValidatableStaticContent(valueNodes) || "";
|
|
416
|
-
this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
|
|
513
|
+
this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, originalAttributeName, parentNode: node });
|
|
417
514
|
}
|
|
418
515
|
else if (staticAttributeName && hasOutputERB) {
|
|
419
516
|
const combinedValue = getAttributeValue(attributeNode);
|
|
420
|
-
this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
|
|
517
|
+
this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, originalAttributeName, combinedValue });
|
|
421
518
|
}
|
|
422
519
|
else if (isDynamicName && staticAttributeValue !== null) {
|
|
423
520
|
const nameNode = attributeNode.name;
|
|
@@ -437,28 +534,38 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
437
534
|
/**
|
|
438
535
|
* Static attribute name with static value: class="container"
|
|
439
536
|
*/
|
|
440
|
-
checkStaticAttributeStaticValue(
|
|
537
|
+
checkStaticAttributeStaticValue(_params) {
|
|
441
538
|
// Default implementation does nothing
|
|
442
539
|
}
|
|
443
540
|
/**
|
|
444
541
|
* Static attribute name with dynamic value: class="<%= css_class %>"
|
|
445
542
|
*/
|
|
446
|
-
checkStaticAttributeDynamicValue(
|
|
543
|
+
checkStaticAttributeDynamicValue(_params) {
|
|
447
544
|
// Default implementation does nothing
|
|
448
545
|
}
|
|
449
546
|
/**
|
|
450
547
|
* Dynamic attribute name with static value: data-<%= key %>="foo"
|
|
451
548
|
*/
|
|
452
|
-
checkDynamicAttributeStaticValue(
|
|
549
|
+
checkDynamicAttributeStaticValue(_params) {
|
|
453
550
|
// Default implementation does nothing
|
|
454
551
|
}
|
|
455
552
|
/**
|
|
456
553
|
* Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
|
|
457
554
|
*/
|
|
458
|
-
checkDynamicAttributeDynamicValue(
|
|
555
|
+
checkDynamicAttributeDynamicValue(_params) {
|
|
459
556
|
// Default implementation does nothing
|
|
460
557
|
}
|
|
461
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* Checks if an attribute value is quoted
|
|
561
|
+
*/
|
|
562
|
+
function isAttributeValueQuoted(attributeNode) {
|
|
563
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
564
|
+
const valueNode = attributeNode.value;
|
|
565
|
+
return !!valueNode.quoted;
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
462
569
|
/**
|
|
463
570
|
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
464
571
|
*/
|
|
@@ -470,6 +577,60 @@ function forEachAttribute(node, callback) {
|
|
|
470
577
|
}
|
|
471
578
|
}
|
|
472
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Base lexer visitor class that provides common functionality for lexer-based rule visitors
|
|
582
|
+
*/
|
|
583
|
+
class BaseLexerRuleVisitor {
|
|
584
|
+
offenses = [];
|
|
585
|
+
ruleName;
|
|
586
|
+
context;
|
|
587
|
+
constructor(ruleName, context) {
|
|
588
|
+
this.ruleName = ruleName;
|
|
589
|
+
this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Helper method to create a lint offense for lexer rules
|
|
593
|
+
*/
|
|
594
|
+
createOffense(message, location, severity = "error") {
|
|
595
|
+
return {
|
|
596
|
+
rule: this.ruleName,
|
|
597
|
+
code: this.ruleName,
|
|
598
|
+
source: "Herb Linter",
|
|
599
|
+
message,
|
|
600
|
+
location,
|
|
601
|
+
severity,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Helper method to add an offense to the offenses array
|
|
606
|
+
*/
|
|
607
|
+
addOffense(message, location, severity = "error") {
|
|
608
|
+
this.offenses.push(this.createOffense(message, location, severity));
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Main entry point for lexer rule visitors
|
|
612
|
+
* @param lexResult - The lexer result containing tokens and source
|
|
613
|
+
*/
|
|
614
|
+
visit(lexResult) {
|
|
615
|
+
this.visitTokens(lexResult.value.tokens);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Visit all tokens
|
|
619
|
+
* Override this method to implement token-level checks
|
|
620
|
+
*/
|
|
621
|
+
visitTokens(tokens) {
|
|
622
|
+
for (const token of tokens) {
|
|
623
|
+
this.visitToken(token);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Visit individual tokens
|
|
628
|
+
* Override this method to implement per-token checks
|
|
629
|
+
*/
|
|
630
|
+
visitToken(_token) {
|
|
631
|
+
// Default implementation does nothing
|
|
632
|
+
}
|
|
633
|
+
}
|
|
473
634
|
/**
|
|
474
635
|
* Base source visitor class that provides common functionality for source-based rule visitors
|
|
475
636
|
*/
|
|
@@ -612,82 +773,734 @@ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
|
612
773
|
}
|
|
613
774
|
}
|
|
614
775
|
|
|
776
|
+
class PrintContext {
|
|
777
|
+
output = "";
|
|
778
|
+
indentLevel = 0;
|
|
779
|
+
currentColumn = 0;
|
|
780
|
+
preserveStack = [];
|
|
781
|
+
/**
|
|
782
|
+
* Write text to the output
|
|
783
|
+
*/
|
|
784
|
+
write(text) {
|
|
785
|
+
this.output += text;
|
|
786
|
+
this.currentColumn += text.length;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Write text and update column tracking for newlines
|
|
790
|
+
*/
|
|
791
|
+
writeWithColumnTracking(text) {
|
|
792
|
+
this.output += text;
|
|
793
|
+
const lines = text.split('\n');
|
|
794
|
+
if (lines.length > 1) {
|
|
795
|
+
this.currentColumn = lines[lines.length - 1].length;
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
this.currentColumn += text.length;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Increase indentation level
|
|
803
|
+
*/
|
|
804
|
+
indent() {
|
|
805
|
+
this.indentLevel++;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Decrease indentation level
|
|
809
|
+
*/
|
|
810
|
+
dedent() {
|
|
811
|
+
if (this.indentLevel > 0) {
|
|
812
|
+
this.indentLevel--;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Enter a tag that may preserve whitespace
|
|
817
|
+
*/
|
|
818
|
+
enterTag(tagName) {
|
|
819
|
+
this.preserveStack.push(tagName.toLowerCase());
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Exit the current tag
|
|
823
|
+
*/
|
|
824
|
+
exitTag() {
|
|
825
|
+
this.preserveStack.pop();
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Check if we're at the start of a line
|
|
829
|
+
*/
|
|
830
|
+
isAtStartOfLine() {
|
|
831
|
+
return this.currentColumn === 0;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Get current indentation level
|
|
835
|
+
*/
|
|
836
|
+
getCurrentIndentLevel() {
|
|
837
|
+
return this.indentLevel;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Get current column position
|
|
841
|
+
*/
|
|
842
|
+
getCurrentColumn() {
|
|
843
|
+
return this.currentColumn;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Get the current tag stack (for debugging)
|
|
847
|
+
*/
|
|
848
|
+
getTagStack() {
|
|
849
|
+
return [...this.preserveStack];
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Get the complete output string
|
|
853
|
+
*/
|
|
854
|
+
getOutput() {
|
|
855
|
+
return this.output;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Reset the context for reuse
|
|
859
|
+
*/
|
|
860
|
+
reset() {
|
|
861
|
+
this.output = "";
|
|
862
|
+
this.indentLevel = 0;
|
|
863
|
+
this.currentColumn = 0;
|
|
864
|
+
this.preserveStack = [];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Default print options used when none are provided
|
|
870
|
+
*/
|
|
871
|
+
const DEFAULT_PRINT_OPTIONS = {
|
|
872
|
+
ignoreErrors: false
|
|
873
|
+
};
|
|
874
|
+
class Printer extends core.Visitor {
|
|
875
|
+
context = new PrintContext();
|
|
876
|
+
/**
|
|
877
|
+
* Static method to print a node without creating an instance
|
|
878
|
+
*
|
|
879
|
+
* @param input - The AST Node, Token, or ParseResult to print
|
|
880
|
+
* @param options - Print options to control behavior
|
|
881
|
+
* @returns The printed string representation of the input
|
|
882
|
+
* @throws {Error} When node has parse errors and ignoreErrors is false
|
|
883
|
+
*/
|
|
884
|
+
static print(input, options = DEFAULT_PRINT_OPTIONS) {
|
|
885
|
+
const printer = new this();
|
|
886
|
+
return printer.print(input, options);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Print a node, token, or parse result to a string
|
|
890
|
+
*
|
|
891
|
+
* @param input - The AST Node, Token, or ParseResult to print
|
|
892
|
+
* @param options - Print options to control behavior
|
|
893
|
+
* @returns The printed string representation of the input
|
|
894
|
+
* @throws {Error} When node has parse errors and ignoreErrors is false
|
|
895
|
+
*/
|
|
896
|
+
print(input, options = DEFAULT_PRINT_OPTIONS) {
|
|
897
|
+
if (!input)
|
|
898
|
+
return "";
|
|
899
|
+
if (core.isToken(input)) {
|
|
900
|
+
return input.value;
|
|
901
|
+
}
|
|
902
|
+
if (Array.isArray(input)) {
|
|
903
|
+
this.context.reset();
|
|
904
|
+
input.forEach(node => this.visit(node));
|
|
905
|
+
return this.context.getOutput();
|
|
906
|
+
}
|
|
907
|
+
const node = core.isParseResult(input) ? input.value : input;
|
|
908
|
+
if (options.ignoreErrors === false && node.recursiveErrors().length > 0) {
|
|
909
|
+
throw new Error(`Cannot print the node (${node.type}) since it or any of its children has parse errors. Either pass in a valid Node or call \`print()\` using \`print(node, { ignoreErrors: true })\``);
|
|
910
|
+
}
|
|
911
|
+
this.context.reset();
|
|
912
|
+
this.visit(node);
|
|
913
|
+
return this.context.getOutput();
|
|
914
|
+
}
|
|
915
|
+
write(content) {
|
|
916
|
+
this.context.write(content);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* IdentityPrinter - Provides lossless reconstruction of the original source
|
|
922
|
+
*
|
|
923
|
+
* This printer aims to reconstruct the original input as faithfully as possible,
|
|
924
|
+
* preserving all whitespace, formatting, and structure. It's useful for:
|
|
925
|
+
* - Testing parser accuracy (input should equal output)
|
|
926
|
+
* - Baseline printing before applying transformations
|
|
927
|
+
* - Verifying AST round-trip fidelity
|
|
928
|
+
*/
|
|
929
|
+
class IdentityPrinter extends Printer {
|
|
930
|
+
visitLiteralNode(node) {
|
|
931
|
+
this.write(node.content);
|
|
932
|
+
}
|
|
933
|
+
visitHTMLTextNode(node) {
|
|
934
|
+
this.write(node.content);
|
|
935
|
+
}
|
|
936
|
+
visitWhitespaceNode(node) {
|
|
937
|
+
if (node.value) {
|
|
938
|
+
this.write(node.value.value);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
visitHTMLOpenTagNode(node) {
|
|
942
|
+
if (node.tag_opening) {
|
|
943
|
+
this.write(node.tag_opening.value);
|
|
944
|
+
}
|
|
945
|
+
if (node.tag_name) {
|
|
946
|
+
this.write(node.tag_name.value);
|
|
947
|
+
}
|
|
948
|
+
this.visitChildNodes(node);
|
|
949
|
+
if (node.tag_closing) {
|
|
950
|
+
this.write(node.tag_closing.value);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
visitHTMLCloseTagNode(node) {
|
|
954
|
+
if (node.tag_opening) {
|
|
955
|
+
this.write(node.tag_opening.value);
|
|
956
|
+
}
|
|
957
|
+
if (node.tag_name) {
|
|
958
|
+
const before = core.getNodesBeforePosition(node.children, node.tag_name.location.start, true);
|
|
959
|
+
const after = core.getNodesAfterPosition(node.children, node.tag_name.location.end);
|
|
960
|
+
this.visitAll(before);
|
|
961
|
+
this.write(node.tag_name.value);
|
|
962
|
+
this.visitAll(after);
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
this.visitAll(node.children);
|
|
966
|
+
}
|
|
967
|
+
if (node.tag_closing) {
|
|
968
|
+
this.write(node.tag_closing.value);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
visitHTMLElementNode(node) {
|
|
972
|
+
const tagName = node.tag_name?.value;
|
|
973
|
+
if (tagName) {
|
|
974
|
+
this.context.enterTag(tagName);
|
|
975
|
+
}
|
|
976
|
+
if (node.open_tag) {
|
|
977
|
+
this.visit(node.open_tag);
|
|
978
|
+
}
|
|
979
|
+
if (node.body) {
|
|
980
|
+
node.body.forEach(child => this.visit(child));
|
|
981
|
+
}
|
|
982
|
+
if (node.close_tag) {
|
|
983
|
+
this.visit(node.close_tag);
|
|
984
|
+
}
|
|
985
|
+
if (tagName) {
|
|
986
|
+
this.context.exitTag();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
visitHTMLAttributeNode(node) {
|
|
990
|
+
if (node.name) {
|
|
991
|
+
this.visit(node.name);
|
|
992
|
+
}
|
|
993
|
+
if (node.equals) {
|
|
994
|
+
this.write(node.equals.value);
|
|
995
|
+
}
|
|
996
|
+
if (node.equals && node.value) {
|
|
997
|
+
this.visit(node.value);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
visitHTMLAttributeNameNode(node) {
|
|
1001
|
+
this.visitChildNodes(node);
|
|
1002
|
+
}
|
|
1003
|
+
visitHTMLAttributeValueNode(node) {
|
|
1004
|
+
if (node.quoted && node.open_quote) {
|
|
1005
|
+
this.write(node.open_quote.value);
|
|
1006
|
+
}
|
|
1007
|
+
this.visitChildNodes(node);
|
|
1008
|
+
if (node.quoted && node.close_quote) {
|
|
1009
|
+
this.write(node.close_quote.value);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
visitHTMLCommentNode(node) {
|
|
1013
|
+
if (node.comment_start) {
|
|
1014
|
+
this.write(node.comment_start.value);
|
|
1015
|
+
}
|
|
1016
|
+
this.visitChildNodes(node);
|
|
1017
|
+
if (node.comment_end) {
|
|
1018
|
+
this.write(node.comment_end.value);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
visitHTMLDoctypeNode(node) {
|
|
1022
|
+
if (node.tag_opening) {
|
|
1023
|
+
this.write(node.tag_opening.value);
|
|
1024
|
+
}
|
|
1025
|
+
this.visitChildNodes(node);
|
|
1026
|
+
if (node.tag_closing) {
|
|
1027
|
+
this.write(node.tag_closing.value);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
visitXMLDeclarationNode(node) {
|
|
1031
|
+
if (node.tag_opening) {
|
|
1032
|
+
this.write(node.tag_opening.value);
|
|
1033
|
+
}
|
|
1034
|
+
this.visitChildNodes(node);
|
|
1035
|
+
if (node.tag_closing) {
|
|
1036
|
+
this.write(node.tag_closing.value);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
visitCDATANode(node) {
|
|
1040
|
+
if (node.tag_opening) {
|
|
1041
|
+
this.write(node.tag_opening.value);
|
|
1042
|
+
}
|
|
1043
|
+
this.visitChildNodes(node);
|
|
1044
|
+
if (node.tag_closing) {
|
|
1045
|
+
this.write(node.tag_closing.value);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
visitERBContentNode(node) {
|
|
1049
|
+
this.printERBNode(node);
|
|
1050
|
+
}
|
|
1051
|
+
visitERBIfNode(node) {
|
|
1052
|
+
this.printERBNode(node);
|
|
1053
|
+
if (node.statements) {
|
|
1054
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1055
|
+
}
|
|
1056
|
+
if (node.subsequent) {
|
|
1057
|
+
this.visit(node.subsequent);
|
|
1058
|
+
}
|
|
1059
|
+
if (node.end_node) {
|
|
1060
|
+
this.visit(node.end_node);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
visitERBElseNode(node) {
|
|
1064
|
+
this.printERBNode(node);
|
|
1065
|
+
if (node.statements) {
|
|
1066
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
visitERBEndNode(node) {
|
|
1070
|
+
this.printERBNode(node);
|
|
1071
|
+
}
|
|
1072
|
+
visitERBBlockNode(node) {
|
|
1073
|
+
this.printERBNode(node);
|
|
1074
|
+
if (node.body) {
|
|
1075
|
+
node.body.forEach(child => this.visit(child));
|
|
1076
|
+
}
|
|
1077
|
+
if (node.end_node) {
|
|
1078
|
+
this.visit(node.end_node);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
visitERBCaseNode(node) {
|
|
1082
|
+
this.printERBNode(node);
|
|
1083
|
+
if (node.children) {
|
|
1084
|
+
node.children.forEach(child => this.visit(child));
|
|
1085
|
+
}
|
|
1086
|
+
if (node.conditions) {
|
|
1087
|
+
node.conditions.forEach(condition => this.visit(condition));
|
|
1088
|
+
}
|
|
1089
|
+
if (node.else_clause) {
|
|
1090
|
+
this.visit(node.else_clause);
|
|
1091
|
+
}
|
|
1092
|
+
if (node.end_node) {
|
|
1093
|
+
this.visit(node.end_node);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
visitERBWhenNode(node) {
|
|
1097
|
+
this.printERBNode(node);
|
|
1098
|
+
if (node.statements) {
|
|
1099
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
visitERBWhileNode(node) {
|
|
1103
|
+
this.printERBNode(node);
|
|
1104
|
+
if (node.statements) {
|
|
1105
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1106
|
+
}
|
|
1107
|
+
if (node.end_node) {
|
|
1108
|
+
this.visit(node.end_node);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
visitERBUntilNode(node) {
|
|
1112
|
+
this.printERBNode(node);
|
|
1113
|
+
if (node.statements) {
|
|
1114
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1115
|
+
}
|
|
1116
|
+
if (node.end_node) {
|
|
1117
|
+
this.visit(node.end_node);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
visitERBForNode(node) {
|
|
1121
|
+
this.printERBNode(node);
|
|
1122
|
+
if (node.statements) {
|
|
1123
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1124
|
+
}
|
|
1125
|
+
if (node.end_node) {
|
|
1126
|
+
this.visit(node.end_node);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
visitERBBeginNode(node) {
|
|
1130
|
+
this.printERBNode(node);
|
|
1131
|
+
if (node.statements) {
|
|
1132
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1133
|
+
}
|
|
1134
|
+
if (node.rescue_clause) {
|
|
1135
|
+
this.visit(node.rescue_clause);
|
|
1136
|
+
}
|
|
1137
|
+
if (node.else_clause) {
|
|
1138
|
+
this.visit(node.else_clause);
|
|
1139
|
+
}
|
|
1140
|
+
if (node.ensure_clause) {
|
|
1141
|
+
this.visit(node.ensure_clause);
|
|
1142
|
+
}
|
|
1143
|
+
if (node.end_node) {
|
|
1144
|
+
this.visit(node.end_node);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
visitERBRescueNode(node) {
|
|
1148
|
+
this.printERBNode(node);
|
|
1149
|
+
if (node.statements) {
|
|
1150
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1151
|
+
}
|
|
1152
|
+
if (node.subsequent) {
|
|
1153
|
+
this.visit(node.subsequent);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
visitERBEnsureNode(node) {
|
|
1157
|
+
this.printERBNode(node);
|
|
1158
|
+
if (node.statements) {
|
|
1159
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
visitERBUnlessNode(node) {
|
|
1163
|
+
this.printERBNode(node);
|
|
1164
|
+
if (node.statements) {
|
|
1165
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1166
|
+
}
|
|
1167
|
+
if (node.else_clause) {
|
|
1168
|
+
this.visit(node.else_clause);
|
|
1169
|
+
}
|
|
1170
|
+
if (node.end_node) {
|
|
1171
|
+
this.visit(node.end_node);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
visitERBYieldNode(node) {
|
|
1175
|
+
this.printERBNode(node);
|
|
1176
|
+
}
|
|
1177
|
+
visitERBInNode(node) {
|
|
1178
|
+
this.printERBNode(node);
|
|
1179
|
+
if (node.statements) {
|
|
1180
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
visitERBCaseMatchNode(node) {
|
|
1184
|
+
this.printERBNode(node);
|
|
1185
|
+
if (node.children) {
|
|
1186
|
+
node.children.forEach(child => this.visit(child));
|
|
1187
|
+
}
|
|
1188
|
+
if (node.conditions) {
|
|
1189
|
+
node.conditions.forEach(condition => this.visit(condition));
|
|
1190
|
+
}
|
|
1191
|
+
if (node.else_clause) {
|
|
1192
|
+
this.visit(node.else_clause);
|
|
1193
|
+
}
|
|
1194
|
+
if (node.end_node) {
|
|
1195
|
+
this.visit(node.end_node);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Print ERB node tags and content
|
|
1200
|
+
*/
|
|
1201
|
+
printERBNode(node) {
|
|
1202
|
+
if (node.tag_opening) {
|
|
1203
|
+
this.write(node.tag_opening.value);
|
|
1204
|
+
}
|
|
1205
|
+
if (node.content) {
|
|
1206
|
+
this.write(node.content.value);
|
|
1207
|
+
}
|
|
1208
|
+
if (node.tag_closing) {
|
|
1209
|
+
this.write(node.tag_closing.value);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
|
|
1215
|
+
...DEFAULT_PRINT_OPTIONS,
|
|
1216
|
+
forceQuotes: false
|
|
1217
|
+
};
|
|
1218
|
+
/**
|
|
1219
|
+
* ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
|
|
1220
|
+
*
|
|
1221
|
+
* This printer transforms ERB templates into Ruby strings by:
|
|
1222
|
+
* - Converting literal text to string content
|
|
1223
|
+
* - Converting <%= %> tags to #{} interpolation
|
|
1224
|
+
* - Converting simple if/else blocks to ternary operators
|
|
1225
|
+
* - Ignoring <% %> tags (they don't produce output)
|
|
1226
|
+
*
|
|
1227
|
+
* Examples:
|
|
1228
|
+
* - `hello world <%= hello %>` => `"hello world #{hello}"`
|
|
1229
|
+
* - `hello world <% hello %>` => `"hello world "`
|
|
1230
|
+
* - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
|
|
1231
|
+
* - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
|
|
1232
|
+
* - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
|
|
1233
|
+
*/
|
|
1234
|
+
class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
1235
|
+
// TODO: cleanup `.type === "AST_*" checks`
|
|
1236
|
+
static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
|
|
1237
|
+
const erbNodes = core.filterNodes([node], core.ERBContentNode);
|
|
1238
|
+
if (erbNodes.length === 1 && core.isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
|
|
1239
|
+
return (erbNodes[0].content?.value || "").trim();
|
|
1240
|
+
}
|
|
1241
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
1242
|
+
const childErbNodes = core.filterNodes(node.children, core.ERBContentNode);
|
|
1243
|
+
const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length;
|
|
1244
|
+
if (hasOnlyERBContent && childErbNodes.length === 1 && core.isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
|
|
1245
|
+
return (childErbNodes[0].content?.value || "").trim();
|
|
1246
|
+
}
|
|
1247
|
+
if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
|
|
1248
|
+
const ifNode = node.children[0];
|
|
1249
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1250
|
+
if (printer.canConvertToTernary(ifNode)) {
|
|
1251
|
+
printer.convertToTernaryWithoutWrapper(ifNode);
|
|
1252
|
+
return printer.context.getOutput();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
|
|
1256
|
+
const unlessNode = node.children[0];
|
|
1257
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1258
|
+
if (printer.canConvertUnlessToTernary(unlessNode)) {
|
|
1259
|
+
printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
|
|
1260
|
+
return printer.context.getOutput();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1265
|
+
printer.context.write('"');
|
|
1266
|
+
printer.visit(node);
|
|
1267
|
+
printer.context.write('"');
|
|
1268
|
+
return printer.context.getOutput();
|
|
1269
|
+
}
|
|
1270
|
+
visitHTMLTextNode(node) {
|
|
1271
|
+
if (node.content) {
|
|
1272
|
+
const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1273
|
+
this.context.write(escapedContent);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
visitERBContentNode(node) {
|
|
1277
|
+
if (core.isERBOutputNode(node)) {
|
|
1278
|
+
this.context.write("#{");
|
|
1279
|
+
if (node.content?.value) {
|
|
1280
|
+
this.context.write(node.content.value.trim());
|
|
1281
|
+
}
|
|
1282
|
+
this.context.write("}");
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
visitERBIfNode(node) {
|
|
1286
|
+
if (this.canConvertToTernary(node)) {
|
|
1287
|
+
this.convertToTernary(node);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
visitERBUnlessNode(node) {
|
|
1291
|
+
if (this.canConvertUnlessToTernary(node)) {
|
|
1292
|
+
this.convertUnlessToTernary(node);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
visitHTMLAttributeValueNode(node) {
|
|
1296
|
+
this.visitChildNodes(node);
|
|
1297
|
+
}
|
|
1298
|
+
canConvertToTernary(node) {
|
|
1299
|
+
if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
|
|
1303
|
+
if (!ifOnlyText)
|
|
1304
|
+
return false;
|
|
1305
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
|
|
1306
|
+
return node.subsequent.statements
|
|
1307
|
+
? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
|
|
1308
|
+
: true;
|
|
1309
|
+
}
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
convertToTernary(node) {
|
|
1313
|
+
this.context.write("#{");
|
|
1314
|
+
if (node.content?.value) {
|
|
1315
|
+
const condition = node.content.value.trim();
|
|
1316
|
+
const cleanCondition = condition.replace(/^if\s+/, '');
|
|
1317
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1318
|
+
if (needsParentheses) {
|
|
1319
|
+
this.context.write("(");
|
|
1320
|
+
}
|
|
1321
|
+
this.context.write(cleanCondition);
|
|
1322
|
+
if (needsParentheses) {
|
|
1323
|
+
this.context.write(")");
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
this.context.write(" ? ");
|
|
1327
|
+
this.context.write('"');
|
|
1328
|
+
if (node.statements) {
|
|
1329
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1330
|
+
}
|
|
1331
|
+
this.context.write('"');
|
|
1332
|
+
this.context.write(" : ");
|
|
1333
|
+
this.context.write('"');
|
|
1334
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
|
|
1335
|
+
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
1336
|
+
}
|
|
1337
|
+
this.context.write('"');
|
|
1338
|
+
this.context.write("}");
|
|
1339
|
+
}
|
|
1340
|
+
convertToTernaryWithoutWrapper(node) {
|
|
1341
|
+
if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
|
|
1342
|
+
return false;
|
|
1343
|
+
}
|
|
1344
|
+
if (node.content?.value) {
|
|
1345
|
+
const condition = node.content.value.trim();
|
|
1346
|
+
const cleanCondition = condition.replace(/^if\s+/, '');
|
|
1347
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1348
|
+
if (needsParentheses) {
|
|
1349
|
+
this.context.write("(");
|
|
1350
|
+
}
|
|
1351
|
+
this.context.write(cleanCondition);
|
|
1352
|
+
if (needsParentheses) {
|
|
1353
|
+
this.context.write(")");
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
this.context.write(" ? ");
|
|
1357
|
+
this.context.write('"');
|
|
1358
|
+
if (node.statements) {
|
|
1359
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1360
|
+
}
|
|
1361
|
+
this.context.write('"');
|
|
1362
|
+
this.context.write(" : ");
|
|
1363
|
+
this.context.write('"');
|
|
1364
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
|
|
1365
|
+
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
1366
|
+
}
|
|
1367
|
+
this.context.write('"');
|
|
1368
|
+
}
|
|
1369
|
+
canConvertUnlessToTernary(node) {
|
|
1370
|
+
const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
|
|
1371
|
+
if (!unlessOnlyText)
|
|
1372
|
+
return false;
|
|
1373
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1374
|
+
return node.else_clause.statements
|
|
1375
|
+
? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
|
|
1376
|
+
: true;
|
|
1377
|
+
}
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
convertUnlessToTernary(node) {
|
|
1381
|
+
this.context.write("#{");
|
|
1382
|
+
if (node.content?.value) {
|
|
1383
|
+
const condition = node.content.value.trim();
|
|
1384
|
+
const cleanCondition = condition.replace(/^unless\s+/, '');
|
|
1385
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1386
|
+
this.context.write("!(");
|
|
1387
|
+
if (needsParentheses) {
|
|
1388
|
+
this.context.write("(");
|
|
1389
|
+
}
|
|
1390
|
+
this.context.write(cleanCondition);
|
|
1391
|
+
if (needsParentheses) {
|
|
1392
|
+
this.context.write(")");
|
|
1393
|
+
}
|
|
1394
|
+
this.context.write(")");
|
|
1395
|
+
}
|
|
1396
|
+
this.context.write(" ? ");
|
|
1397
|
+
this.context.write('"');
|
|
1398
|
+
if (node.statements) {
|
|
1399
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1400
|
+
}
|
|
1401
|
+
this.context.write('"');
|
|
1402
|
+
this.context.write(" : ");
|
|
1403
|
+
this.context.write('"');
|
|
1404
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1405
|
+
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
1406
|
+
}
|
|
1407
|
+
this.context.write('"');
|
|
1408
|
+
this.context.write("}");
|
|
1409
|
+
}
|
|
1410
|
+
convertUnlessToTernaryWithoutWrapper(node) {
|
|
1411
|
+
if (node.content?.value) {
|
|
1412
|
+
const condition = node.content.value.trim();
|
|
1413
|
+
const cleanCondition = condition.replace(/^unless\s+/, '');
|
|
1414
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1415
|
+
this.context.write("!(");
|
|
1416
|
+
if (needsParentheses) {
|
|
1417
|
+
this.context.write("(");
|
|
1418
|
+
}
|
|
1419
|
+
this.context.write(cleanCondition);
|
|
1420
|
+
if (needsParentheses) {
|
|
1421
|
+
this.context.write(")");
|
|
1422
|
+
}
|
|
1423
|
+
this.context.write(")");
|
|
1424
|
+
}
|
|
1425
|
+
this.context.write(" ? ");
|
|
1426
|
+
this.context.write('"');
|
|
1427
|
+
if (node.statements) {
|
|
1428
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1429
|
+
}
|
|
1430
|
+
this.context.write('"');
|
|
1431
|
+
this.context.write(" : ");
|
|
1432
|
+
this.context.write('"');
|
|
1433
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1434
|
+
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
1435
|
+
}
|
|
1436
|
+
this.context.write('"');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
615
1440
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
616
1441
|
visitHTMLOpenTagNode(node) {
|
|
617
1442
|
this.checkImgTag(node);
|
|
618
1443
|
super.visitHTMLOpenTagNode(node);
|
|
619
1444
|
}
|
|
620
|
-
checkImgTag(
|
|
621
|
-
const tagName = getTagName(
|
|
622
|
-
if (tagName !== "img")
|
|
1445
|
+
checkImgTag(openTag) {
|
|
1446
|
+
const tagName = getTagName(openTag);
|
|
1447
|
+
if (tagName !== "img")
|
|
623
1448
|
return;
|
|
624
|
-
|
|
625
|
-
const attributes = getAttributes(node);
|
|
1449
|
+
const attributes = getAttributes(openTag);
|
|
626
1450
|
const srcAttribute = findAttributeByName(attributes, "src");
|
|
627
|
-
if (!srcAttribute)
|
|
1451
|
+
if (!srcAttribute)
|
|
628
1452
|
return;
|
|
629
|
-
|
|
630
|
-
if (!srcAttribute.value) {
|
|
1453
|
+
if (!srcAttribute.value)
|
|
631
1454
|
return;
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
const hasERBContent = this.containsERBContent(valueNode);
|
|
1455
|
+
const node = srcAttribute.value;
|
|
1456
|
+
const hasERBContent = this.containsERBContent(node);
|
|
635
1457
|
if (hasERBContent) {
|
|
636
|
-
|
|
637
|
-
|
|
1458
|
+
if (this.isDataUri(node))
|
|
1459
|
+
return;
|
|
1460
|
+
if (this.shouldFlagAsImageTagCandidate(node)) {
|
|
1461
|
+
const suggestedExpression = this.buildSuggestedExpression(node);
|
|
1462
|
+
this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
containsERBContent(node) {
|
|
1467
|
+
return core.filterNodes(node.children, core.ERBContentNode).length > 0;
|
|
1468
|
+
}
|
|
1469
|
+
isOnlyERBContent(node) {
|
|
1470
|
+
return node.children.length > 0 && node.children.length === core.filterNodes(node.children, core.ERBContentNode).length;
|
|
1471
|
+
}
|
|
1472
|
+
getContentofFirstChild(node) {
|
|
1473
|
+
if (!node.children || node.children.length === 0)
|
|
1474
|
+
return "";
|
|
1475
|
+
const firstChild = node.children[0];
|
|
1476
|
+
if (core.isNode(firstChild, core.LiteralNode)) {
|
|
1477
|
+
return (firstChild.content || "").trim();
|
|
638
1478
|
}
|
|
1479
|
+
return "";
|
|
639
1480
|
}
|
|
640
|
-
|
|
641
|
-
|
|
1481
|
+
isDataUri(node) {
|
|
1482
|
+
return this.getContentofFirstChild(node).startsWith("data:");
|
|
1483
|
+
}
|
|
1484
|
+
isFullUrl(node) {
|
|
1485
|
+
const content = this.getContentofFirstChild(node);
|
|
1486
|
+
return content.startsWith("http://") || content.startsWith("https://");
|
|
1487
|
+
}
|
|
1488
|
+
shouldFlagAsImageTagCandidate(node) {
|
|
1489
|
+
if (this.isOnlyERBContent(node))
|
|
1490
|
+
return true;
|
|
1491
|
+
if (this.isFullUrl(node))
|
|
642
1492
|
return false;
|
|
643
|
-
return
|
|
1493
|
+
return true;
|
|
644
1494
|
}
|
|
645
|
-
buildSuggestedExpression(
|
|
646
|
-
if (!
|
|
1495
|
+
buildSuggestedExpression(node) {
|
|
1496
|
+
if (!node.children)
|
|
647
1497
|
return "expression";
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
for (const child of valueNode.children) {
|
|
651
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
652
|
-
hasERB = true;
|
|
653
|
-
}
|
|
654
|
-
else if (child.type === "AST_LITERAL_NODE") {
|
|
655
|
-
const literalNode = child;
|
|
656
|
-
if (literalNode.content && literalNode.content.trim()) {
|
|
657
|
-
hasText = true;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
if (hasText && hasERB) {
|
|
662
|
-
let result = '"';
|
|
663
|
-
for (const child of valueNode.children) {
|
|
664
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
665
|
-
const erbNode = child;
|
|
666
|
-
result += `#{${(erbNode.content?.value || "").trim()}}`;
|
|
667
|
-
}
|
|
668
|
-
else if (child.type === "AST_LITERAL_NODE") {
|
|
669
|
-
const literalNode = child;
|
|
670
|
-
result += literalNode.content || "";
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
result += '"';
|
|
674
|
-
return result;
|
|
1498
|
+
try {
|
|
1499
|
+
return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
|
|
675
1500
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (erbNodes.length === 1) {
|
|
679
|
-
return (erbNodes[0].content?.value || "").trim();
|
|
680
|
-
}
|
|
681
|
-
else if (erbNodes.length > 1) {
|
|
682
|
-
let result = '"';
|
|
683
|
-
for (const erbNode of erbNodes) {
|
|
684
|
-
result += `#{${(erbNode.content?.value || "").trim()}}`;
|
|
685
|
-
}
|
|
686
|
-
result += '"';
|
|
687
|
-
return result;
|
|
688
|
-
}
|
|
1501
|
+
catch {
|
|
1502
|
+
return "expression";
|
|
689
1503
|
}
|
|
690
|
-
return "expression";
|
|
691
1504
|
}
|
|
692
1505
|
}
|
|
693
1506
|
class ERBPreferImageTagHelperRule extends ParserRule {
|
|
@@ -1069,19 +1882,18 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
|
|
|
1069
1882
|
}
|
|
1070
1883
|
|
|
1071
1884
|
class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
1072
|
-
checkStaticAttributeStaticValue({
|
|
1073
|
-
|
|
1074
|
-
return;
|
|
1075
|
-
if (!hasAttributeValue(attributeNode))
|
|
1076
|
-
return;
|
|
1077
|
-
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
|
|
1885
|
+
checkStaticAttributeStaticValue({ originalAttributeName, attributeNode }) {
|
|
1886
|
+
this.checkAttribute(originalAttributeName, attributeNode);
|
|
1078
1887
|
}
|
|
1079
|
-
checkStaticAttributeDynamicValue({
|
|
1888
|
+
checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }) {
|
|
1889
|
+
this.checkAttribute(originalAttributeName, attributeNode);
|
|
1890
|
+
}
|
|
1891
|
+
checkAttribute(attributeName, attributeNode) {
|
|
1080
1892
|
if (!isBooleanAttribute(attributeName))
|
|
1081
1893
|
return;
|
|
1082
1894
|
if (!hasAttributeValue(attributeNode))
|
|
1083
1895
|
return;
|
|
1084
|
-
this.addOffense(`Boolean attribute \`${
|
|
1896
|
+
this.addOffense(`Boolean attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not have a value. Use \`${attributeName.toLowerCase()}\` instead of \`${IdentityPrinter.print(attributeNode)}\`.`, attributeNode.value.location, "error");
|
|
1085
1897
|
}
|
|
1086
1898
|
}
|
|
1087
1899
|
class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
@@ -1154,47 +1966,6 @@ class HTMLImgRequireAltRule extends ParserRule {
|
|
|
1154
1966
|
}
|
|
1155
1967
|
}
|
|
1156
1968
|
|
|
1157
|
-
class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
1158
|
-
visitHTMLOpenTagNode(node) {
|
|
1159
|
-
this.checkNavigationElement(node);
|
|
1160
|
-
super.visitHTMLOpenTagNode(node);
|
|
1161
|
-
}
|
|
1162
|
-
checkNavigationElement(node) {
|
|
1163
|
-
const tagName = getTagName(node);
|
|
1164
|
-
const isNavElement = tagName === "nav";
|
|
1165
|
-
const hasNavigationRole = this.hasRoleNavigation(node);
|
|
1166
|
-
if (!isNavElement && !hasNavigationRole) {
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
const hasAriaLabel = hasAttribute(node, "aria-label");
|
|
1170
|
-
const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
|
|
1171
|
-
if (!hasAriaLabel && !hasAriaLabelledby) {
|
|
1172
|
-
let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
|
|
1173
|
-
if (hasNavigationRole && !isNavElement) {
|
|
1174
|
-
message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
|
|
1175
|
-
}
|
|
1176
|
-
this.addOffense(message, node.tag_name.location, "error");
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
hasRoleNavigation(node) {
|
|
1180
|
-
const attributes = getAttributes(node);
|
|
1181
|
-
const roleAttribute = findAttributeByName(attributes, "role");
|
|
1182
|
-
if (!roleAttribute) {
|
|
1183
|
-
return false;
|
|
1184
|
-
}
|
|
1185
|
-
const roleValue = getAttributeValue(roleAttribute);
|
|
1186
|
-
return roleValue === "navigation";
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
class HTMLNavigationHasLabelRule extends ParserRule {
|
|
1190
|
-
name = "html-navigation-has-label";
|
|
1191
|
-
check(result, context) {
|
|
1192
|
-
const visitor = new NavigationHasLabelVisitor(this.name, context);
|
|
1193
|
-
visitor.visit(result.value);
|
|
1194
|
-
return visitor.offenses;
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
1969
|
const INTERACTIVE_ELEMENTS = new Set([
|
|
1199
1970
|
"button", "summary", "input", "select", "textarea", "a"
|
|
1200
1971
|
]);
|
|
@@ -1299,19 +2070,141 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
|
1299
2070
|
}
|
|
1300
2071
|
}
|
|
1301
2072
|
|
|
1302
|
-
class
|
|
2073
|
+
class OutputPrinter extends Printer {
|
|
2074
|
+
visitLiteralNode(node) {
|
|
2075
|
+
this.write(IdentityPrinter.print(node));
|
|
2076
|
+
}
|
|
2077
|
+
visitERBContentNode(node) {
|
|
2078
|
+
if (core.isERBOutputNode(node)) {
|
|
2079
|
+
this.write(IdentityPrinter.print(node));
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
|
|
1303
2084
|
documentIds = new Set();
|
|
1304
|
-
|
|
1305
|
-
|
|
2085
|
+
currentBranchIds = new Set();
|
|
2086
|
+
controlFlowIds = new Set();
|
|
2087
|
+
visitHTMLAttributeNode(node) {
|
|
2088
|
+
this.checkAttribute(node);
|
|
2089
|
+
}
|
|
2090
|
+
onEnterControlFlow(_controlFlowType, wasAlreadyInControlFlow) {
|
|
2091
|
+
const stateToRestore = {
|
|
2092
|
+
previousBranchIds: this.currentBranchIds,
|
|
2093
|
+
previousControlFlowIds: this.controlFlowIds
|
|
2094
|
+
};
|
|
2095
|
+
this.currentBranchIds = new Set();
|
|
2096
|
+
if (!wasAlreadyInControlFlow) {
|
|
2097
|
+
this.controlFlowIds = new Set();
|
|
2098
|
+
}
|
|
2099
|
+
return stateToRestore;
|
|
2100
|
+
}
|
|
2101
|
+
onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
|
|
2102
|
+
if (controlFlowType === exports.ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
|
|
2103
|
+
this.controlFlowIds.forEach(id => this.documentIds.add(id));
|
|
2104
|
+
}
|
|
2105
|
+
this.currentBranchIds = stateToRestore.previousBranchIds;
|
|
2106
|
+
this.controlFlowIds = stateToRestore.previousControlFlowIds;
|
|
2107
|
+
}
|
|
2108
|
+
onEnterBranch() {
|
|
2109
|
+
const stateToRestore = {
|
|
2110
|
+
previousBranchIds: this.currentBranchIds
|
|
2111
|
+
};
|
|
2112
|
+
if (this.isInControlFlow) {
|
|
2113
|
+
this.currentBranchIds = new Set();
|
|
2114
|
+
}
|
|
2115
|
+
return stateToRestore;
|
|
2116
|
+
}
|
|
2117
|
+
onExitBranch(_stateToRestore) { }
|
|
2118
|
+
checkAttribute(attributeNode) {
|
|
2119
|
+
if (!this.isIdAttribute(attributeNode))
|
|
1306
2120
|
return;
|
|
1307
|
-
|
|
2121
|
+
const idValue = this.extractIdValue(attributeNode);
|
|
2122
|
+
if (!idValue)
|
|
2123
|
+
return;
|
|
2124
|
+
if (this.isWhitespaceOnlyId(idValue.identifier))
|
|
2125
|
+
return;
|
|
2126
|
+
this.processIdDuplicate(idValue, attributeNode);
|
|
2127
|
+
}
|
|
2128
|
+
isIdAttribute(attributeNode) {
|
|
2129
|
+
if (!attributeNode.name?.children || !attributeNode.value)
|
|
2130
|
+
return false;
|
|
2131
|
+
return core.getStaticAttributeName(attributeNode.name) === "id";
|
|
2132
|
+
}
|
|
2133
|
+
extractIdValue(attributeNode) {
|
|
2134
|
+
const valueNodes = attributeNode.value?.children || [];
|
|
2135
|
+
if (core.hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === exports.ControlFlowType.LOOP) {
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
const identifier = core.isEffectivelyStatic(valueNodes) ? core.getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
|
|
2139
|
+
if (!identifier)
|
|
2140
|
+
return null;
|
|
2141
|
+
return { identifier, shouldTrackDuplicates: true };
|
|
2142
|
+
}
|
|
2143
|
+
isWhitespaceOnlyId(identifier) {
|
|
2144
|
+
return identifier !== '' && identifier.trim() === '';
|
|
2145
|
+
}
|
|
2146
|
+
processIdDuplicate(idValue, attributeNode) {
|
|
2147
|
+
const { identifier, shouldTrackDuplicates } = idValue;
|
|
2148
|
+
if (!shouldTrackDuplicates)
|
|
1308
2149
|
return;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
2150
|
+
if (this.isInControlFlow) {
|
|
2151
|
+
this.handleControlFlowId(identifier, attributeNode);
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
this.handleGlobalId(identifier, attributeNode);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
handleControlFlowId(identifier, attributeNode) {
|
|
2158
|
+
if (this.currentControlFlowType === exports.ControlFlowType.LOOP) {
|
|
2159
|
+
this.handleLoopId(identifier, attributeNode);
|
|
2160
|
+
}
|
|
2161
|
+
else {
|
|
2162
|
+
this.handleConditionalId(identifier, attributeNode);
|
|
2163
|
+
}
|
|
2164
|
+
this.currentBranchIds.add(identifier);
|
|
2165
|
+
}
|
|
2166
|
+
handleLoopId(identifier, attributeNode) {
|
|
2167
|
+
const isStaticId = this.isStaticId(attributeNode);
|
|
2168
|
+
if (isStaticId) {
|
|
2169
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
2173
|
+
this.addSameLoopIterationOffense(identifier, attributeNode.location);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
handleConditionalId(identifier, attributeNode) {
|
|
2177
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
2178
|
+
this.addSameBranchOffense(identifier, attributeNode.location);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (this.documentIds.has(identifier)) {
|
|
2182
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
this.controlFlowIds.add(identifier);
|
|
2186
|
+
}
|
|
2187
|
+
handleGlobalId(identifier, attributeNode) {
|
|
2188
|
+
if (this.documentIds.has(identifier)) {
|
|
2189
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
1312
2190
|
return;
|
|
1313
2191
|
}
|
|
1314
|
-
this.documentIds.add(
|
|
2192
|
+
this.documentIds.add(identifier);
|
|
2193
|
+
}
|
|
2194
|
+
isStaticId(attributeNode) {
|
|
2195
|
+
const valueNodes = attributeNode.value.children;
|
|
2196
|
+
const isCompletelyStatic = valueNodes.every(child => core.isNode(child, core.LiteralNode));
|
|
2197
|
+
const isEffectivelyStaticValue = core.isEffectivelyStatic(valueNodes);
|
|
2198
|
+
return isCompletelyStatic || isEffectivelyStaticValue;
|
|
2199
|
+
}
|
|
2200
|
+
addDuplicateIdOffense(identifier, location) {
|
|
2201
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`, location, "error");
|
|
2202
|
+
}
|
|
2203
|
+
addSameLoopIterationOffense(identifier, location) {
|
|
2204
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`, location, "error");
|
|
2205
|
+
}
|
|
2206
|
+
addSameBranchOffense(identifier, location) {
|
|
2207
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`, location, "error");
|
|
1315
2208
|
}
|
|
1316
2209
|
}
|
|
1317
2210
|
class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
@@ -1323,6 +2216,60 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
|
1323
2216
|
}
|
|
1324
2217
|
}
|
|
1325
2218
|
|
|
2219
|
+
// Attributes that must not have empty values
|
|
2220
|
+
const RESTRICTED_ATTRIBUTES = new Set([
|
|
2221
|
+
'id',
|
|
2222
|
+
'class',
|
|
2223
|
+
'name',
|
|
2224
|
+
'for',
|
|
2225
|
+
'src',
|
|
2226
|
+
'href',
|
|
2227
|
+
'title',
|
|
2228
|
+
'data',
|
|
2229
|
+
'role'
|
|
2230
|
+
]);
|
|
2231
|
+
// Check if attribute name matches any restricted patterns
|
|
2232
|
+
function isRestrictedAttribute(attributeName) {
|
|
2233
|
+
// Check direct matches
|
|
2234
|
+
if (RESTRICTED_ATTRIBUTES.has(attributeName)) {
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
// Check for data-* attributes
|
|
2238
|
+
if (attributeName.startsWith('data-')) {
|
|
2239
|
+
return true;
|
|
2240
|
+
}
|
|
2241
|
+
// Check for aria-* attributes
|
|
2242
|
+
if (attributeName.startsWith('aria-')) {
|
|
2243
|
+
return true;
|
|
2244
|
+
}
|
|
2245
|
+
return false;
|
|
2246
|
+
}
|
|
2247
|
+
class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
|
|
2248
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
2249
|
+
if (!isRestrictedAttribute(attributeName))
|
|
2250
|
+
return;
|
|
2251
|
+
if (attributeValue.trim() !== "")
|
|
2252
|
+
return;
|
|
2253
|
+
this.addOffense(`Attribute \`${attributeName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
|
|
2254
|
+
}
|
|
2255
|
+
checkDynamicAttributeStaticValue({ combinedName, attributeValue, attributeNode }) {
|
|
2256
|
+
const name = (combinedName || "").toLowerCase();
|
|
2257
|
+
if (!isRestrictedAttribute(name))
|
|
2258
|
+
return;
|
|
2259
|
+
if (attributeValue.trim() !== "")
|
|
2260
|
+
return;
|
|
2261
|
+
this.addOffense(`Attribute \`${combinedName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
2265
|
+
name = "html-no-empty-attributes";
|
|
2266
|
+
check(result, context) {
|
|
2267
|
+
const visitor = new NoEmptyAttributesVisitor(this.name, context);
|
|
2268
|
+
visitor.visit(result.value);
|
|
2269
|
+
return visitor.offenses;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1326
2273
|
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
1327
2274
|
visitHTMLElementNode(node) {
|
|
1328
2275
|
this.checkHeadingElement(node);
|
|
@@ -1499,7 +2446,7 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
|
|
|
1499
2446
|
return;
|
|
1500
2447
|
const tabIndexValue = parseInt(attributeValue, 10);
|
|
1501
2448
|
if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
|
|
1502
|
-
this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex
|
|
2449
|
+
this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex="-1"\` to remove it from the tab sequence.`, attributeNode.location, "error");
|
|
1503
2450
|
}
|
|
1504
2451
|
}
|
|
1505
2452
|
}
|
|
@@ -1513,14 +2460,20 @@ class HTMLNoPositiveTabIndexRule extends ParserRule {
|
|
|
1513
2460
|
}
|
|
1514
2461
|
|
|
1515
2462
|
class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
2463
|
+
visitHTMLElementNode(node) {
|
|
2464
|
+
if (core.getTagName(node) === "svg") {
|
|
2465
|
+
this.visit(node.open_tag);
|
|
2466
|
+
}
|
|
2467
|
+
else {
|
|
2468
|
+
this.visitChildNodes(node);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
1516
2471
|
visitHTMLOpenTagNode(node) {
|
|
1517
2472
|
if (node.tag_closing?.value === "/>") {
|
|
1518
|
-
const tagName = getTagName(node);
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
this.addOffense(`Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`, node.location, "error");
|
|
2473
|
+
const tagName = core.getTagName(node);
|
|
2474
|
+
const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
|
|
2475
|
+
this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, "error");
|
|
1522
2476
|
}
|
|
1523
|
-
super.visitHTMLOpenTagNode(node);
|
|
1524
2477
|
}
|
|
1525
2478
|
}
|
|
1526
2479
|
class HTMLNoSelfClosingRule extends ParserRule {
|
|
@@ -1532,31 +2485,6 @@ class HTMLNoSelfClosingRule extends ParserRule {
|
|
|
1532
2485
|
}
|
|
1533
2486
|
}
|
|
1534
2487
|
|
|
1535
|
-
class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
1536
|
-
ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
|
|
1537
|
-
visitHTMLOpenTagNode(node) {
|
|
1538
|
-
this.checkTitleAttribute(node);
|
|
1539
|
-
super.visitHTMLOpenTagNode(node);
|
|
1540
|
-
}
|
|
1541
|
-
checkTitleAttribute(node) {
|
|
1542
|
-
const tagName = getTagName(node);
|
|
1543
|
-
if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
|
-
if (hasAttribute(node, "title")) {
|
|
1547
|
-
this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
class HTMLNoTitleAttributeRule extends ParserRule {
|
|
1552
|
-
name = "html-no-title-attribute";
|
|
1553
|
-
check(result, context) {
|
|
1554
|
-
const visitor = new NoTitleAttributeVisitor(this.name, context);
|
|
1555
|
-
visitor.visit(result.value);
|
|
1556
|
-
return visitor.offenses;
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
2488
|
class XMLDeclarationChecker extends BaseRuleVisitor {
|
|
1561
2489
|
hasXMLDeclaration = false;
|
|
1562
2490
|
visitXMLDeclarationNode(_node) {
|
|
@@ -1663,9 +2591,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1663
2591
|
const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
|
|
1664
2592
|
if (correctCamelCase && tagName !== correctCamelCase) {
|
|
1665
2593
|
let type = node.type;
|
|
1666
|
-
if (node.type
|
|
2594
|
+
if (node.type === "AST_HTML_OPEN_TAG_NODE")
|
|
1667
2595
|
type = "Opening";
|
|
1668
|
-
if (node.type
|
|
2596
|
+
if (node.type === "AST_HTML_CLOSE_TAG_NODE")
|
|
1669
2597
|
type = "Closing";
|
|
1670
2598
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
1671
2599
|
}
|
|
@@ -1680,6 +2608,38 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
1680
2608
|
}
|
|
1681
2609
|
}
|
|
1682
2610
|
|
|
2611
|
+
class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
|
|
2612
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
2613
|
+
this.check(attributeName, attributeNode);
|
|
2614
|
+
}
|
|
2615
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
|
|
2616
|
+
this.check(attributeName, attributeNode);
|
|
2617
|
+
}
|
|
2618
|
+
checkDynamicAttributeStaticValue({ nameNodes, attributeNode }) {
|
|
2619
|
+
const attributeName = core.getStaticContentFromNodes(nameNodes);
|
|
2620
|
+
this.check(attributeName, attributeNode);
|
|
2621
|
+
}
|
|
2622
|
+
checkDynamicAttributeDynamicValue({ nameNodes, attributeNode }) {
|
|
2623
|
+
const attributeName = core.getStaticContentFromNodes(nameNodes);
|
|
2624
|
+
this.check(attributeName, attributeNode);
|
|
2625
|
+
}
|
|
2626
|
+
check(attributeName, attributeNode) {
|
|
2627
|
+
if (!attributeName)
|
|
2628
|
+
return;
|
|
2629
|
+
if (attributeName.includes("_")) {
|
|
2630
|
+
this.addOffense(`Attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not contain underscores. Use hyphens (-) instead.`, attributeNode.value.location, "warning");
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
|
|
2635
|
+
name = "html-no-underscores-in-attribute-names";
|
|
2636
|
+
check(result, context) {
|
|
2637
|
+
const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context);
|
|
2638
|
+
visitor.visit(result.value);
|
|
2639
|
+
return visitor.offenses;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
1683
2643
|
const defaultRules = [
|
|
1684
2644
|
ERBNoEmptyTagsRule,
|
|
1685
2645
|
ERBNoOutputControlFlowRule,
|
|
@@ -1700,19 +2660,21 @@ const defaultRules = [
|
|
|
1700
2660
|
HTMLBooleanAttributesNoValueRule,
|
|
1701
2661
|
HTMLIframeHasTitleRule,
|
|
1702
2662
|
HTMLImgRequireAltRule,
|
|
1703
|
-
HTMLNavigationHasLabelRule,
|
|
2663
|
+
// HTMLNavigationHasLabelRule,
|
|
1704
2664
|
HTMLNoAriaHiddenOnFocusableRule,
|
|
1705
2665
|
// HTMLNoBlockInsideInlineRule,
|
|
1706
2666
|
HTMLNoDuplicateAttributesRule,
|
|
1707
2667
|
HTMLNoDuplicateIdsRule,
|
|
2668
|
+
HTMLNoEmptyAttributesRule,
|
|
1708
2669
|
HTMLNoEmptyHeadingsRule,
|
|
1709
2670
|
HTMLNoNestedLinksRule,
|
|
1710
2671
|
HTMLNoPositiveTabIndexRule,
|
|
1711
2672
|
HTMLNoSelfClosingRule,
|
|
1712
|
-
HTMLNoTitleAttributeRule,
|
|
2673
|
+
// HTMLNoTitleAttributeRule,
|
|
1713
2674
|
HTMLTagNameLowercaseRule,
|
|
1714
2675
|
ParserNoErrorsRule,
|
|
1715
2676
|
SVGTagNameCapitalizationRule,
|
|
2677
|
+
HTMLNoUnderscoresInAttributeNamesRule,
|
|
1716
2678
|
];
|
|
1717
2679
|
|
|
1718
2680
|
class Linter {
|
|
@@ -1809,6 +2771,47 @@ class Linter {
|
|
|
1809
2771
|
}
|
|
1810
2772
|
}
|
|
1811
2773
|
|
|
2774
|
+
class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
2775
|
+
visitHTMLOpenTagNode(node) {
|
|
2776
|
+
this.checkNavigationElement(node);
|
|
2777
|
+
super.visitHTMLOpenTagNode(node);
|
|
2778
|
+
}
|
|
2779
|
+
checkNavigationElement(node) {
|
|
2780
|
+
const tagName = getTagName(node);
|
|
2781
|
+
const isNavElement = tagName === "nav";
|
|
2782
|
+
const hasNavigationRole = this.hasRoleNavigation(node);
|
|
2783
|
+
if (!isNavElement && !hasNavigationRole) {
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const hasAriaLabel = hasAttribute(node, "aria-label");
|
|
2787
|
+
const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
|
|
2788
|
+
if (!hasAriaLabel && !hasAriaLabelledby) {
|
|
2789
|
+
let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
|
|
2790
|
+
if (hasNavigationRole && !isNavElement) {
|
|
2791
|
+
message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
|
|
2792
|
+
}
|
|
2793
|
+
this.addOffense(message, node.tag_name.location, "error");
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
hasRoleNavigation(node) {
|
|
2797
|
+
const attributes = getAttributes(node);
|
|
2798
|
+
const roleAttribute = findAttributeByName(attributes, "role");
|
|
2799
|
+
if (!roleAttribute) {
|
|
2800
|
+
return false;
|
|
2801
|
+
}
|
|
2802
|
+
const roleValue = getAttributeValue(roleAttribute);
|
|
2803
|
+
return roleValue === "navigation";
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
class HTMLNavigationHasLabelRule extends ParserRule {
|
|
2807
|
+
name = "html-navigation-has-label";
|
|
2808
|
+
check(result, context) {
|
|
2809
|
+
const visitor = new NavigationHasLabelVisitor(this.name, context);
|
|
2810
|
+
visitor.visit(result.value);
|
|
2811
|
+
return visitor.offenses;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
|
|
1812
2815
|
class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
1813
2816
|
inlineStack = [];
|
|
1814
2817
|
isValidHTMLOpenTag(node) {
|
|
@@ -1867,12 +2870,44 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
|
1867
2870
|
}
|
|
1868
2871
|
}
|
|
1869
2872
|
|
|
2873
|
+
class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
2874
|
+
ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
|
|
2875
|
+
visitHTMLOpenTagNode(node) {
|
|
2876
|
+
this.checkTitleAttribute(node);
|
|
2877
|
+
super.visitHTMLOpenTagNode(node);
|
|
2878
|
+
}
|
|
2879
|
+
checkTitleAttribute(node) {
|
|
2880
|
+
const tagName = getTagName(node);
|
|
2881
|
+
if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
if (hasAttribute(node, "title")) {
|
|
2885
|
+
this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
class HTMLNoTitleAttributeRule extends ParserRule {
|
|
2890
|
+
name = "html-no-title-attribute";
|
|
2891
|
+
check(result, context) {
|
|
2892
|
+
const visitor = new NoTitleAttributeVisitor(this.name, context);
|
|
2893
|
+
visitor.visit(result.value);
|
|
2894
|
+
return visitor.offenses;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
exports.ARIA_ATTRIBUTES = ARIA_ATTRIBUTES;
|
|
2899
|
+
exports.AttributeVisitorMixin = AttributeVisitorMixin;
|
|
2900
|
+
exports.BaseLexerRuleVisitor = BaseLexerRuleVisitor;
|
|
2901
|
+
exports.BaseRuleVisitor = BaseRuleVisitor;
|
|
2902
|
+
exports.BaseSourceRuleVisitor = BaseSourceRuleVisitor;
|
|
2903
|
+
exports.ControlFlowTrackingVisitor = ControlFlowTrackingVisitor;
|
|
1870
2904
|
exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
|
|
1871
2905
|
exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
|
|
1872
2906
|
exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
|
|
1873
2907
|
exports.ERBNoSilentTagInAttributeNameRule = ERBNoSilentTagInAttributeNameRule;
|
|
1874
2908
|
exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
|
|
1875
2909
|
exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
|
|
2910
|
+
exports.HEADING_TAGS = HEADING_TAGS;
|
|
1876
2911
|
exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
|
|
1877
2912
|
exports.HTMLAriaLabelIsWellFormattedRule = HTMLAriaLabelIsWellFormattedRule;
|
|
1878
2913
|
exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
|
|
@@ -1890,15 +2925,48 @@ exports.HTMLNoAriaHiddenOnFocusableRule = HTMLNoAriaHiddenOnFocusableRule;
|
|
|
1890
2925
|
exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
|
|
1891
2926
|
exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
|
|
1892
2927
|
exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
|
|
2928
|
+
exports.HTMLNoEmptyAttributesRule = HTMLNoEmptyAttributesRule;
|
|
1893
2929
|
exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
|
|
1894
2930
|
exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
|
|
1895
2931
|
exports.HTMLNoPositiveTabIndexRule = HTMLNoPositiveTabIndexRule;
|
|
1896
2932
|
exports.HTMLNoSelfClosingRule = HTMLNoSelfClosingRule;
|
|
1897
2933
|
exports.HTMLNoTitleAttributeRule = HTMLNoTitleAttributeRule;
|
|
2934
|
+
exports.HTMLNoUnderscoresInAttributeNamesRule = HTMLNoUnderscoresInAttributeNamesRule;
|
|
1898
2935
|
exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
|
|
2936
|
+
exports.HTML_BLOCK_ELEMENTS = HTML_BLOCK_ELEMENTS;
|
|
2937
|
+
exports.HTML_BOOLEAN_ATTRIBUTES = HTML_BOOLEAN_ATTRIBUTES;
|
|
2938
|
+
exports.HTML_INLINE_ELEMENTS = HTML_INLINE_ELEMENTS;
|
|
2939
|
+
exports.HTML_VOID_ELEMENTS = HTML_VOID_ELEMENTS;
|
|
1899
2940
|
exports.LexerRule = LexerRule;
|
|
1900
2941
|
exports.Linter = Linter;
|
|
1901
2942
|
exports.ParserRule = ParserRule;
|
|
1902
2943
|
exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
|
|
2944
|
+
exports.SVG_CAMEL_CASE_ELEMENTS = SVG_CAMEL_CASE_ELEMENTS;
|
|
2945
|
+
exports.SVG_LOWERCASE_TO_CAMELCASE = SVG_LOWERCASE_TO_CAMELCASE;
|
|
1903
2946
|
exports.SourceRule = SourceRule;
|
|
2947
|
+
exports.VALID_ARIA_ROLES = VALID_ARIA_ROLES;
|
|
2948
|
+
exports.createEndOfFileLocation = createEndOfFileLocation;
|
|
2949
|
+
exports.findAttributeByName = findAttributeByName;
|
|
2950
|
+
exports.forEachAttribute = forEachAttribute;
|
|
2951
|
+
exports.getAttribute = getAttribute;
|
|
2952
|
+
exports.getAttributeName = getAttributeName;
|
|
2953
|
+
exports.getAttributeValue = getAttributeValue;
|
|
2954
|
+
exports.getAttributeValueNodes = getAttributeValueNodes;
|
|
2955
|
+
exports.getAttributeValueQuoteType = getAttributeValueQuoteType;
|
|
2956
|
+
exports.getAttributes = getAttributes;
|
|
2957
|
+
exports.getCombinedAttributeNameString = getCombinedAttributeNameString;
|
|
2958
|
+
exports.getStaticAttributeValue = getStaticAttributeValue;
|
|
2959
|
+
exports.getStaticAttributeValueContent = getStaticAttributeValueContent;
|
|
2960
|
+
exports.getTagName = getTagName;
|
|
2961
|
+
exports.hasAttribute = hasAttribute;
|
|
2962
|
+
exports.hasAttributeValue = hasAttributeValue;
|
|
2963
|
+
exports.hasDynamicAttributeName = hasDynamicAttributeName;
|
|
2964
|
+
exports.hasDynamicAttributeValue = hasDynamicAttributeValue;
|
|
2965
|
+
exports.hasStaticAttributeValue = hasStaticAttributeValue;
|
|
2966
|
+
exports.hasStaticAttributeValueContent = hasStaticAttributeValueContent;
|
|
2967
|
+
exports.isAttributeValueQuoted = isAttributeValueQuoted;
|
|
2968
|
+
exports.isBlockElement = isBlockElement;
|
|
2969
|
+
exports.isBooleanAttribute = isBooleanAttribute;
|
|
2970
|
+
exports.isInlineElement = isInlineElement;
|
|
2971
|
+
exports.isVoidElement = isVoidElement;
|
|
1904
2972
|
//# sourceMappingURL=index.cjs.map
|