zemdomu 1.3.16 → 1.3.17
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/out/linter.d.ts +4 -0
- package/out/linter.js +23 -0
- package/out/project-linter.js +3 -2
- package/out/rule-codes.d.ts +1 -0
- package/out/rule-codes.js +1 -0
- package/out/rules/preventZemdomuPlaceholders.d.ts +2 -0
- package/out/rules/preventZemdomuPlaceholders.js +236 -0
- package/out/rules/requireButtonText.js +282 -23
- package/out/rules/requireLabelForFormControls.js +216 -26
- package/out/rules/requireSectionHeading.js +238 -18
- package/out/rules/requireTableCaption.js +91 -9
- package/out/src/rules/preventZemdomuPlaceholders.js +236 -0
- package/out/tests/button-accessibility-jsx.test.js +53 -0
- package/out/tests/crossComponent/cross-button-text.test.js +31 -0
- package/out/tests/label-form-control-jsx.test.js +29 -0
- package/out/tests/prevent-placeholders-jsx.test.js +34 -0
- package/out/tests/section-heading-jsx.test.js +32 -0
- package/package.json +1 -1
package/out/linter.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface Rule {
|
|
|
32
32
|
name: string;
|
|
33
33
|
/** Called before traversal begins */
|
|
34
34
|
init?: () => void;
|
|
35
|
+
setHtmlContext?: (ctx: {
|
|
36
|
+
content: string;
|
|
37
|
+
lineIndex: number[];
|
|
38
|
+
}) => void;
|
|
35
39
|
enterHtml?: (node: Node) => LintResult[];
|
|
36
40
|
exitHtml?: (node: Node) => LintResult[];
|
|
37
41
|
enterJsx?: (path: NodePath<t.JSXElement>) => LintResult[];
|
package/out/linter.js
CHANGED
|
@@ -24,6 +24,7 @@ const requireImageInputAlt_1 = __importDefault(require("./rules/requireImageInpu
|
|
|
24
24
|
const requireNavLinks_1 = __importDefault(require("./rules/requireNavLinks"));
|
|
25
25
|
const uniqueIds_1 = __importDefault(require("./rules/uniqueIds"));
|
|
26
26
|
const noTabindexGreaterThanZero_1 = __importDefault(require("./rules/noTabindexGreaterThanZero"));
|
|
27
|
+
const preventZemdomuPlaceholders_1 = __importDefault(require("./rules/preventZemdomuPlaceholders"));
|
|
27
28
|
const rule_codes_1 = require("./rule-codes");
|
|
28
29
|
const builtInRules = {
|
|
29
30
|
requireSectionHeading: requireSectionHeading_1.default,
|
|
@@ -43,6 +44,7 @@ const builtInRules = {
|
|
|
43
44
|
requireNavLinks: requireNavLinks_1.default,
|
|
44
45
|
uniqueIds: uniqueIds_1.default,
|
|
45
46
|
noTabindexGreaterThanZero: noTabindexGreaterThanZero_1.default,
|
|
47
|
+
preventZemdomuPlaceholders: preventZemdomuPlaceholders_1.default,
|
|
46
48
|
};
|
|
47
49
|
const defaultOptions = {
|
|
48
50
|
rules: {
|
|
@@ -63,9 +65,18 @@ const defaultOptions = {
|
|
|
63
65
|
requireNavLinks: "warning",
|
|
64
66
|
uniqueIds: "error",
|
|
65
67
|
noTabindexGreaterThanZero: "warning",
|
|
68
|
+
preventZemdomuPlaceholders: "warning",
|
|
66
69
|
},
|
|
67
70
|
customRules: [],
|
|
68
71
|
};
|
|
72
|
+
function buildLineIndex(content) {
|
|
73
|
+
const lines = [0];
|
|
74
|
+
for (let i = 0; i < content.length; i++) {
|
|
75
|
+
if (content[i] === "\n")
|
|
76
|
+
lines.push(i + 1);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
69
80
|
/**
|
|
70
81
|
* Lint HTML/JSX/TSX content.
|
|
71
82
|
*/
|
|
@@ -209,6 +220,18 @@ function lint(content, options = defaultOptions) {
|
|
|
209
220
|
if (onlyComments) {
|
|
210
221
|
parseErrors = [];
|
|
211
222
|
}
|
|
223
|
+
const lineIndex = buildLineIndex(content);
|
|
224
|
+
activeRules.forEach(({ rule }) => {
|
|
225
|
+
var _a;
|
|
226
|
+
if (rule.setHtmlContext) {
|
|
227
|
+
try {
|
|
228
|
+
rule.setHtmlContext({ content, lineIndex });
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
console.error(`[ZemDomu] Error in rule ${rule.name} (${(_a = opts.filePath) !== null && _a !== void 0 ? _a : "unknown"}):`, e);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
212
235
|
const walk = (node) => {
|
|
213
236
|
var _a, _b, _c;
|
|
214
237
|
for (const { rule, severity } of activeRules) {
|
package/out/project-linter.js
CHANGED
|
@@ -72,13 +72,14 @@ class ProjectLinter {
|
|
|
72
72
|
filePath,
|
|
73
73
|
forceHtml: isVue || this.opts.forceHtml,
|
|
74
74
|
});
|
|
75
|
+
const resolvedResults = results.map((result) => result.filePath ? result : { ...result, filePath });
|
|
75
76
|
const byFile = new Map();
|
|
76
|
-
byFile.set(filePath, [...
|
|
77
|
+
byFile.set(filePath, [...resolvedResults]);
|
|
77
78
|
const xmlMode = /\.(jsx|tsx)$/.test(filePath) || isVue;
|
|
78
79
|
if (xmlMode) {
|
|
79
80
|
const component = await this.analyzer.analyzeFile(filePath);
|
|
80
81
|
if (component)
|
|
81
|
-
this.analyzer.registerComponent(component,
|
|
82
|
+
this.analyzer.registerComponent(component, resolvedResults);
|
|
82
83
|
if (this.opts.crossComponentAnalysis) {
|
|
83
84
|
const cross = this.analyzer.analyzeComponentTree();
|
|
84
85
|
for (const r of cross) {
|
package/out/rule-codes.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ declare const RULE_CODES: {
|
|
|
16
16
|
readonly requireNavLinks: "ZMD015";
|
|
17
17
|
readonly uniqueIds: "ZMD016";
|
|
18
18
|
readonly noTabindexGreaterThanZero: "ZMD017";
|
|
19
|
+
readonly preventZemdomuPlaceholders: "ZMD018";
|
|
19
20
|
};
|
|
20
21
|
export declare function getRuleCode(rule: string): string | undefined;
|
|
21
22
|
export declare function applyRuleCode<T extends {
|
package/out/rule-codes.js
CHANGED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.default = preventZemdomuPlaceholders;
|
|
37
|
+
const t = __importStar(require("@babel/types"));
|
|
38
|
+
const PLACEHOLDER = "TODO-ZMD";
|
|
39
|
+
function buildLineIndex(content) {
|
|
40
|
+
const lines = [0];
|
|
41
|
+
for (let i = 0; i < content.length; i++) {
|
|
42
|
+
if (content[i] === "\n")
|
|
43
|
+
lines.push(i + 1);
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
function indexToLoc(lineIndex, index) {
|
|
48
|
+
let low = 0;
|
|
49
|
+
let high = lineIndex.length - 1;
|
|
50
|
+
while (low <= high) {
|
|
51
|
+
const mid = (low + high) >> 1;
|
|
52
|
+
if (lineIndex[mid] <= index) {
|
|
53
|
+
low = mid + 1;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
high = mid - 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const line = Math.max(high, 0);
|
|
60
|
+
const column = index - lineIndex[line];
|
|
61
|
+
return { line, column };
|
|
62
|
+
}
|
|
63
|
+
function collectOccurrences(text, startLine, startColumn) {
|
|
64
|
+
const hits = [];
|
|
65
|
+
let idx = text.indexOf(PLACEHOLDER);
|
|
66
|
+
while (idx !== -1) {
|
|
67
|
+
const before = text.slice(0, idx);
|
|
68
|
+
const parts = before.split(/\r?\n/);
|
|
69
|
+
const lineOffset = parts.length - 1;
|
|
70
|
+
const column = lineOffset === 0 ? startColumn + parts[parts.length - 1].length : parts[parts.length - 1].length;
|
|
71
|
+
hits.push({ line: startLine + lineOffset, column });
|
|
72
|
+
idx = text.indexOf(PLACEHOLDER, idx + PLACEHOLDER.length);
|
|
73
|
+
}
|
|
74
|
+
return hits;
|
|
75
|
+
}
|
|
76
|
+
function addHtmlResults(results, htmlContext, absoluteIndex) {
|
|
77
|
+
if (!htmlContext) {
|
|
78
|
+
results.push({
|
|
79
|
+
line: 0,
|
|
80
|
+
column: 0,
|
|
81
|
+
message: "Unresolved Zemdomu placeholder detected",
|
|
82
|
+
rule: "preventZemdomuPlaceholders",
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const loc = indexToLoc(htmlContext.lineIndex, absoluteIndex);
|
|
87
|
+
results.push({
|
|
88
|
+
line: loc.line,
|
|
89
|
+
column: loc.column,
|
|
90
|
+
message: "Unresolved Zemdomu placeholder detected",
|
|
91
|
+
rule: "preventZemdomuPlaceholders",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function textNodePlaceholderResults(node, htmlContext) {
|
|
95
|
+
if (node.type !== "text")
|
|
96
|
+
return [];
|
|
97
|
+
const occurrences = [];
|
|
98
|
+
let idx = node.text.indexOf(PLACEHOLDER);
|
|
99
|
+
while (idx !== -1) {
|
|
100
|
+
occurrences.push(node.startIndex + idx);
|
|
101
|
+
idx = node.text.indexOf(PLACEHOLDER, idx + PLACEHOLDER.length);
|
|
102
|
+
}
|
|
103
|
+
if (!occurrences.length)
|
|
104
|
+
return [];
|
|
105
|
+
const results = [];
|
|
106
|
+
for (const absIdx of occurrences) {
|
|
107
|
+
addHtmlResults(results, htmlContext, absIdx);
|
|
108
|
+
}
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
function tagPlaceholderResults(node, htmlContext) {
|
|
112
|
+
if (!htmlContext) {
|
|
113
|
+
for (const value of Object.values(node.attrs)) {
|
|
114
|
+
if (value && value.includes(PLACEHOLDER)) {
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
line: 0,
|
|
118
|
+
column: 0,
|
|
119
|
+
message: "Unresolved Zemdomu placeholder detected",
|
|
120
|
+
rule: "preventZemdomuPlaceholders",
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const { content } = htmlContext;
|
|
128
|
+
const tagEnd = content.indexOf(">", node.startIndex);
|
|
129
|
+
if (tagEnd === -1)
|
|
130
|
+
return [];
|
|
131
|
+
const tagSource = content.slice(node.startIndex, tagEnd + 1);
|
|
132
|
+
const results = [];
|
|
133
|
+
let idx = tagSource.indexOf(PLACEHOLDER);
|
|
134
|
+
while (idx !== -1) {
|
|
135
|
+
addHtmlResults(results, htmlContext, node.startIndex + idx);
|
|
136
|
+
idx = tagSource.indexOf(PLACEHOLDER, idx + PLACEHOLDER.length);
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
function resultsFromJsxText(text, loc) {
|
|
141
|
+
if (!loc)
|
|
142
|
+
return [];
|
|
143
|
+
const startLine = loc.start.line - 1;
|
|
144
|
+
const startColumn = loc.start.column;
|
|
145
|
+
const hits = collectOccurrences(text, startLine, startColumn);
|
|
146
|
+
return hits.map((hit) => ({
|
|
147
|
+
line: hit.line,
|
|
148
|
+
column: hit.column,
|
|
149
|
+
message: "Unresolved Zemdomu placeholder detected",
|
|
150
|
+
rule: "preventZemdomuPlaceholders",
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
function checkJsxExpression(expr) {
|
|
154
|
+
if (t.isStringLiteral(expr)) {
|
|
155
|
+
return { text: expr.value, loc: expr.loc };
|
|
156
|
+
}
|
|
157
|
+
if (t.isTemplateLiteral(expr)) {
|
|
158
|
+
const raw = expr.quasis.map((q) => { var _a; return (_a = q.value.cooked) !== null && _a !== void 0 ? _a : q.value.raw; }).join("");
|
|
159
|
+
return { text: raw, loc: expr.loc };
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function jsxAttributeResults(attr) {
|
|
164
|
+
var _a, _b, _c;
|
|
165
|
+
const value = attr.value;
|
|
166
|
+
if (!value)
|
|
167
|
+
return [];
|
|
168
|
+
if (t.isStringLiteral(value)) {
|
|
169
|
+
if (!value.value.includes(PLACEHOLDER))
|
|
170
|
+
return [];
|
|
171
|
+
return resultsFromJsxText(value.value, (_a = value.loc) !== null && _a !== void 0 ? _a : attr.loc);
|
|
172
|
+
}
|
|
173
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
174
|
+
const expr = checkJsxExpression(value.expression);
|
|
175
|
+
if (!expr || !expr.text.includes(PLACEHOLDER))
|
|
176
|
+
return [];
|
|
177
|
+
return resultsFromJsxText(expr.text, (_c = (_b = expr.loc) !== null && _b !== void 0 ? _b : value.loc) !== null && _c !== void 0 ? _c : attr.loc);
|
|
178
|
+
}
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
function jsxChildResults(child) {
|
|
182
|
+
var _a;
|
|
183
|
+
if (t.isJSXText(child)) {
|
|
184
|
+
if (!child.value.includes(PLACEHOLDER))
|
|
185
|
+
return [];
|
|
186
|
+
return resultsFromJsxText(child.value, child.loc);
|
|
187
|
+
}
|
|
188
|
+
if (t.isJSXExpressionContainer(child)) {
|
|
189
|
+
const expr = checkJsxExpression(child.expression);
|
|
190
|
+
if (!expr || !expr.text.includes(PLACEHOLDER))
|
|
191
|
+
return [];
|
|
192
|
+
return resultsFromJsxText(expr.text, (_a = expr.loc) !== null && _a !== void 0 ? _a : child.loc);
|
|
193
|
+
}
|
|
194
|
+
if (t.isJSXFragment(child)) {
|
|
195
|
+
const results = [];
|
|
196
|
+
for (const fragmentChild of child.children) {
|
|
197
|
+
results.push(...jsxChildResults(fragmentChild));
|
|
198
|
+
}
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
function preventZemdomuPlaceholders() {
|
|
204
|
+
let htmlContext = null;
|
|
205
|
+
return {
|
|
206
|
+
name: "preventZemdomuPlaceholders",
|
|
207
|
+
setHtmlContext(ctx) {
|
|
208
|
+
htmlContext = {
|
|
209
|
+
content: ctx.content,
|
|
210
|
+
lineIndex: ctx.lineIndex.length ? ctx.lineIndex : buildLineIndex(ctx.content),
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
enterHtml(node) {
|
|
214
|
+
if (node.type === "text") {
|
|
215
|
+
return textNodePlaceholderResults(node, htmlContext);
|
|
216
|
+
}
|
|
217
|
+
if (node.type === "element") {
|
|
218
|
+
return tagPlaceholderResults(node, htmlContext);
|
|
219
|
+
}
|
|
220
|
+
return [];
|
|
221
|
+
},
|
|
222
|
+
enterJsx(path) {
|
|
223
|
+
const results = [];
|
|
224
|
+
const opening = path.node.openingElement;
|
|
225
|
+
for (const attr of opening.attributes) {
|
|
226
|
+
if (t.isJSXAttribute(attr)) {
|
|
227
|
+
results.push(...jsxAttributeResults(attr));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const child of path.node.children) {
|
|
231
|
+
results.push(...jsxChildResults(child));
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -36,26 +36,245 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.default = requireButtonText;
|
|
37
37
|
const t = __importStar(require("@babel/types"));
|
|
38
38
|
const utils_1 = require("./utils");
|
|
39
|
+
const HTML_ARIA_LABEL_ATTRS = ['aria-label'];
|
|
40
|
+
const HTML_ARIA_LABELLEDBY_ATTRS = ['aria-labelledby'];
|
|
41
|
+
const HTML_ARIA_HIDDEN_ATTRS = ['aria-hidden'];
|
|
42
|
+
const HTML_HIDDEN_ATTRS = ['hidden'];
|
|
43
|
+
function getHtmlAttrValue(attrs, names) {
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(attrs, name)) {
|
|
46
|
+
return attrs[name];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
function hasHtmlAttr(attrs, names) {
|
|
52
|
+
return names.some((name) => Object.prototype.hasOwnProperty.call(attrs, name));
|
|
53
|
+
}
|
|
54
|
+
function normalizeStyle(style) {
|
|
55
|
+
return style.toLowerCase().replace(/\s+/g, '');
|
|
56
|
+
}
|
|
57
|
+
function isHiddenStyle(style) {
|
|
58
|
+
if (!style)
|
|
59
|
+
return false;
|
|
60
|
+
const normalized = normalizeStyle(style);
|
|
61
|
+
return (normalized.includes('display:none') ||
|
|
62
|
+
normalized.includes('visibility:hidden') ||
|
|
63
|
+
normalized.includes('visibility:collapse'));
|
|
64
|
+
}
|
|
65
|
+
function isHtmlHidden(node) {
|
|
66
|
+
if (hasHtmlAttr(node.attrs, HTML_HIDDEN_ATTRS))
|
|
67
|
+
return true;
|
|
68
|
+
const ariaHidden = getHtmlAttrValue(node.attrs, HTML_ARIA_HIDDEN_ATTRS);
|
|
69
|
+
if (typeof ariaHidden === 'string' && ariaHidden.trim().toLowerCase() === 'true') {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const style = node.attrs.style;
|
|
73
|
+
return isHiddenStyle(style);
|
|
74
|
+
}
|
|
75
|
+
function hasHtmlNonEmptyAriaLabel(node) {
|
|
76
|
+
const aria = getHtmlAttrValue(node.attrs, HTML_ARIA_LABEL_ATTRS);
|
|
77
|
+
if (aria === undefined)
|
|
78
|
+
return false;
|
|
79
|
+
return aria.trim().length > 0;
|
|
80
|
+
}
|
|
81
|
+
function htmlImgAltPresent(node) {
|
|
82
|
+
const alt = node.attrs.alt;
|
|
83
|
+
return typeof alt === 'string' && alt.trim().length > 0;
|
|
84
|
+
}
|
|
85
|
+
function hasHtmlAccessibleText(node, hidden) {
|
|
86
|
+
if (hidden)
|
|
87
|
+
return false;
|
|
88
|
+
if (node.type === 'text') {
|
|
89
|
+
return node.text.trim().length > 0;
|
|
90
|
+
}
|
|
91
|
+
if (node.type === 'element') {
|
|
92
|
+
const isHidden = hidden || isHtmlHidden(node);
|
|
93
|
+
if (isHidden)
|
|
94
|
+
return false;
|
|
95
|
+
if (node.tagName === 'img') {
|
|
96
|
+
return htmlImgAltPresent(node);
|
|
97
|
+
}
|
|
98
|
+
return node.children.some((child) => hasHtmlAccessibleText(child, isHidden));
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
function hasHtmlAriaLabelledByText(node, idMap) {
|
|
103
|
+
const labelledBy = getHtmlAttrValue(node.attrs, HTML_ARIA_LABELLEDBY_ATTRS);
|
|
104
|
+
if (!labelledBy)
|
|
105
|
+
return false;
|
|
106
|
+
const ids = labelledBy.trim().split(/\s+/).filter(Boolean);
|
|
107
|
+
if (!ids.length)
|
|
108
|
+
return false;
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const targets = idMap.get(id);
|
|
111
|
+
if (!targets)
|
|
112
|
+
continue;
|
|
113
|
+
for (const target of targets) {
|
|
114
|
+
if (hasHtmlAccessibleText(target, false))
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function getJsxTagName(opening) {
|
|
121
|
+
return t.isJSXIdentifier(opening.name) ? opening.name.name.toLowerCase() : '';
|
|
122
|
+
}
|
|
123
|
+
function getStaticJsxAttrText(opening, name) {
|
|
124
|
+
const attr = (0, utils_1.getJsxAttribute)(opening, name);
|
|
125
|
+
if (!attr)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (!attr.value)
|
|
128
|
+
return '';
|
|
129
|
+
if (t.isStringLiteral(attr.value))
|
|
130
|
+
return attr.value.value;
|
|
131
|
+
if (t.isJSXExpressionContainer(attr.value)) {
|
|
132
|
+
const expr = attr.value.expression;
|
|
133
|
+
if (t.isStringLiteral(expr))
|
|
134
|
+
return expr.value;
|
|
135
|
+
if (t.isTemplateLiteral(expr) && expr.expressions.length === 0) {
|
|
136
|
+
const raw = expr.quasis.map((q) => { var _a; return (_a = q.value.cooked) !== null && _a !== void 0 ? _a : q.value.raw; }).join('');
|
|
137
|
+
return raw;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function isJsxHidden(opening) {
|
|
143
|
+
const attr = (0, utils_1.getJsxAttribute)(opening, 'hidden');
|
|
144
|
+
if (!attr)
|
|
145
|
+
return false;
|
|
146
|
+
if (!attr.value)
|
|
147
|
+
return true;
|
|
148
|
+
if (t.isStringLiteral(attr.value))
|
|
149
|
+
return true;
|
|
150
|
+
if (t.isJSXExpressionContainer(attr.value)) {
|
|
151
|
+
const expr = attr.value.expression;
|
|
152
|
+
if (t.isBooleanLiteral(expr))
|
|
153
|
+
return expr.value;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
function isJsxAriaHidden(opening) {
|
|
158
|
+
const attr = (0, utils_1.getJsxAttribute)(opening, 'aria-hidden');
|
|
159
|
+
if (!attr)
|
|
160
|
+
return false;
|
|
161
|
+
if (!attr.value)
|
|
162
|
+
return true;
|
|
163
|
+
if (t.isStringLiteral(attr.value)) {
|
|
164
|
+
return attr.value.value.trim().toLowerCase() === 'true';
|
|
165
|
+
}
|
|
166
|
+
if (t.isJSXExpressionContainer(attr.value)) {
|
|
167
|
+
const expr = attr.value.expression;
|
|
168
|
+
if (t.isBooleanLiteral(expr))
|
|
169
|
+
return expr.value;
|
|
170
|
+
if (t.isStringLiteral(expr)) {
|
|
171
|
+
return expr.value.trim().toLowerCase() === 'true';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
function isJsxStyleHidden(opening) {
|
|
177
|
+
const attr = (0, utils_1.getJsxAttribute)(opening, 'style');
|
|
178
|
+
if (!attr || !attr.value)
|
|
179
|
+
return false;
|
|
180
|
+
if (t.isStringLiteral(attr.value)) {
|
|
181
|
+
return isHiddenStyle(attr.value.value);
|
|
182
|
+
}
|
|
183
|
+
if (t.isJSXExpressionContainer(attr.value)) {
|
|
184
|
+
const expr = attr.value.expression;
|
|
185
|
+
if (t.isStringLiteral(expr))
|
|
186
|
+
return isHiddenStyle(expr.value);
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
function isJsxHiddenFromAT(opening) {
|
|
191
|
+
return isJsxHidden(opening) || isJsxAriaHidden(opening) || isJsxStyleHidden(opening);
|
|
192
|
+
}
|
|
193
|
+
function mergeStates(states) {
|
|
194
|
+
if (states.some((s) => s === 'present'))
|
|
195
|
+
return 'present';
|
|
196
|
+
if (states.some((s) => s === 'possiblyEmpty'))
|
|
197
|
+
return 'possiblyEmpty';
|
|
198
|
+
return 'empty';
|
|
199
|
+
}
|
|
200
|
+
function jsxImgAltState(opening) {
|
|
201
|
+
const alt = getStaticJsxAttrText(opening, 'alt');
|
|
202
|
+
if (alt === undefined)
|
|
203
|
+
return 'empty';
|
|
204
|
+
if (alt === null)
|
|
205
|
+
return 'empty';
|
|
206
|
+
return alt.trim().length > 0 ? 'present' : 'empty';
|
|
207
|
+
}
|
|
208
|
+
function jsxChildTextState(child, hidden) {
|
|
209
|
+
if (hidden)
|
|
210
|
+
return 'empty';
|
|
211
|
+
if (t.isJSXText(child))
|
|
212
|
+
return child.value.trim().length > 0 ? 'present' : 'empty';
|
|
213
|
+
if (t.isJSXExpressionContainer(child)) {
|
|
214
|
+
const expr = child.expression;
|
|
215
|
+
if (t.isJSXElement(expr))
|
|
216
|
+
return jsxElementTextState(expr, hidden);
|
|
217
|
+
if (t.isJSXFragment(expr)) {
|
|
218
|
+
return mergeStates(expr.children.map((c) => jsxChildTextState(c, hidden)));
|
|
219
|
+
}
|
|
220
|
+
return (0, utils_1.getJsxExpressionState)(expr, true);
|
|
221
|
+
}
|
|
222
|
+
if (t.isJSXElement(child))
|
|
223
|
+
return jsxElementTextState(child, hidden);
|
|
224
|
+
if (t.isJSXFragment(child)) {
|
|
225
|
+
return mergeStates(child.children.map((c) => jsxChildTextState(c, hidden)));
|
|
226
|
+
}
|
|
227
|
+
if (t.isJSXSpreadChild(child))
|
|
228
|
+
return 'present';
|
|
229
|
+
return 'empty';
|
|
230
|
+
}
|
|
231
|
+
function jsxElementTextState(node, parentHidden) {
|
|
232
|
+
const opening = node.openingElement;
|
|
233
|
+
const isHidden = parentHidden || isJsxHiddenFromAT(opening);
|
|
234
|
+
if (isHidden)
|
|
235
|
+
return 'empty';
|
|
236
|
+
const tag = getJsxTagName(opening);
|
|
237
|
+
if (tag === 'img') {
|
|
238
|
+
return jsxImgAltState(opening);
|
|
239
|
+
}
|
|
240
|
+
return mergeStates(node.children.map((c) => jsxChildTextState(c, isHidden)));
|
|
241
|
+
}
|
|
242
|
+
function hasJsxAriaLabelledByText(opening, idMap) {
|
|
243
|
+
const labelledBy = getStaticJsxAttrText(opening, 'aria-labelledby');
|
|
244
|
+
if (!labelledBy)
|
|
245
|
+
return false;
|
|
246
|
+
const ids = labelledBy.trim().split(/\s+/).filter(Boolean);
|
|
247
|
+
if (!ids.length)
|
|
248
|
+
return false;
|
|
249
|
+
for (const id of ids) {
|
|
250
|
+
const targets = idMap.get(id);
|
|
251
|
+
if (!targets)
|
|
252
|
+
continue;
|
|
253
|
+
for (const target of targets) {
|
|
254
|
+
const state = jsxElementTextState(target, false);
|
|
255
|
+
if (state === 'present')
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
39
261
|
function requireButtonText() {
|
|
40
|
-
const
|
|
262
|
+
const htmlButtons = [];
|
|
263
|
+
const htmlIds = new Map();
|
|
264
|
+
const jsxButtons = [];
|
|
265
|
+
const jsxIds = new Map();
|
|
41
266
|
return {
|
|
42
267
|
name: 'requireButtonText',
|
|
43
268
|
enterHtml(node) {
|
|
44
|
-
if (node.type === 'element'
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
},
|
|
54
|
-
exitHtml(node) {
|
|
55
|
-
if (node.type === 'element' && node.tagName === 'button') {
|
|
56
|
-
const info = stack.pop();
|
|
57
|
-
if (info && !info.found) {
|
|
58
|
-
return [{ line: 0, column: 0, message: '<button> missing accessible text', rule: 'requireButtonText' }];
|
|
269
|
+
if (node.type === 'element') {
|
|
270
|
+
const id = node.attrs.id;
|
|
271
|
+
if (id && id.trim().length > 0) {
|
|
272
|
+
if (!htmlIds.has(id))
|
|
273
|
+
htmlIds.set(id, []);
|
|
274
|
+
htmlIds.get(id).push(node);
|
|
275
|
+
}
|
|
276
|
+
if (node.tagName === 'button') {
|
|
277
|
+
htmlButtons.push({ node });
|
|
59
278
|
}
|
|
60
279
|
}
|
|
61
280
|
return [];
|
|
@@ -63,16 +282,56 @@ function requireButtonText() {
|
|
|
63
282
|
enterJsx(path) {
|
|
64
283
|
var _a, _b, _c, _d;
|
|
65
284
|
const opening = path.node.openingElement;
|
|
66
|
-
const tag =
|
|
285
|
+
const tag = getJsxTagName(opening);
|
|
286
|
+
const id = getStaticJsxAttrText(opening, 'id');
|
|
287
|
+
if (typeof id === 'string' && id.trim().length > 0) {
|
|
288
|
+
if (!jsxIds.has(id))
|
|
289
|
+
jsxIds.set(id, []);
|
|
290
|
+
jsxIds.get(id).push(path.node);
|
|
291
|
+
}
|
|
67
292
|
if (tag === 'button') {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const column = (_d = (_c = opening.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
72
|
-
return [{ line, column, message: '<button> missing accessible text', rule: 'requireButtonText' }];
|
|
73
|
-
}
|
|
293
|
+
const line = ((_b = (_a = opening.loc) === null || _a === void 0 ? void 0 : _a.start.line) !== null && _b !== void 0 ? _b : 1) - 1;
|
|
294
|
+
const column = (_d = (_c = opening.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
295
|
+
jsxButtons.push({ node: path.node, line, column });
|
|
74
296
|
}
|
|
75
297
|
return [];
|
|
76
298
|
},
|
|
299
|
+
end() {
|
|
300
|
+
const results = [];
|
|
301
|
+
for (const { node } of htmlButtons) {
|
|
302
|
+
if (isHtmlHidden(node))
|
|
303
|
+
continue;
|
|
304
|
+
const hasLabel = hasHtmlNonEmptyAriaLabel(node);
|
|
305
|
+
const hasLabelledBy = hasHtmlAriaLabelledByText(node, htmlIds);
|
|
306
|
+
const hasContent = hasHtmlAccessibleText(node, false);
|
|
307
|
+
if (!hasLabel && !hasLabelledBy && !hasContent) {
|
|
308
|
+
results.push({
|
|
309
|
+
line: 0,
|
|
310
|
+
column: 0,
|
|
311
|
+
message: '<button> missing accessible text',
|
|
312
|
+
rule: 'requireButtonText',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const { node, line, column } of jsxButtons) {
|
|
317
|
+
const opening = node.openingElement;
|
|
318
|
+
if (isJsxHiddenFromAT(opening))
|
|
319
|
+
continue;
|
|
320
|
+
const ariaState = (0, utils_1.getJsxAttributeState)(opening, 'aria-label', true);
|
|
321
|
+
const hasLabel = ariaState === 'present';
|
|
322
|
+
const hasLabelledBy = hasJsxAriaLabelledByText(opening, jsxIds);
|
|
323
|
+
const contentState = jsxElementTextState(node, false);
|
|
324
|
+
const hasContent = contentState === 'present';
|
|
325
|
+
if (!hasLabel && !hasLabelledBy && !hasContent) {
|
|
326
|
+
results.push({
|
|
327
|
+
line,
|
|
328
|
+
column,
|
|
329
|
+
message: '<button> missing accessible text',
|
|
330
|
+
rule: 'requireButtonText',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
},
|
|
77
336
|
};
|
|
78
337
|
}
|