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