1 | "use strict";
|
2 | var __extends = (this && this.__extends) || (function () {
|
3 | var extendStatics = function (d, b) {
|
4 | extendStatics = Object.setPrototypeOf ||
|
5 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
6 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
|
7 | return extendStatics(d, b);
|
8 | }
|
9 | return function (d, b) {
|
10 | extendStatics(d, b);
|
11 | function __() { this.constructor = d; }
|
12 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
13 | };
|
14 | })();
|
15 | var __makeTemplateObject = (this && this.__makeTemplateObject) || function (cooked, raw) {
|
16 | if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; }
|
17 | return cooked;
|
18 | };
|
19 | Object.defineProperty(exports, "__esModule", { value: true });
|
20 | var ts = require("typescript");
|
21 | var Lint = require("tslint");
|
22 | var tsutils = require("tsutils");
|
23 | var Utils_1 = require("./utils/Utils");
|
24 | var getImplicitRole_1 = require("./utils/getImplicitRole");
|
25 | var JsxAttribute_1 = require("./utils/JsxAttribute");
|
26 | var TypeGuard_1 = require("./utils/TypeGuard");
|
27 | exports.OPTION_IGNORE_CASE = 'ignore-case';
|
28 | exports.OPTION_IGNORE_WHITESPACE = 'ignore-whitespace';
|
29 | var ROLE_STRING = 'role';
|
30 | exports.NO_HASH_FAILURE_STRING = 'Do not use # as anchor href.';
|
31 | exports.MISSING_HREF_FAILURE_STRING = 'Do not leave href undefined or null';
|
32 | exports.LINK_TEXT_TOO_SHORT_FAILURE_STRING = 'Link text or the alt text of image in link should be at least 4 characters long. ' +
|
33 | "If you are not using <a> element as anchor, please specify explicit role, e.g. role='button'";
|
34 | exports.UNIQUE_ALT_FAILURE_STRING = 'Links with images and text content, the alt attribute should be unique to the text content or empty.';
|
35 | exports.SAME_HREF_SAME_TEXT_FAILURE_STRING = 'Links with the same HREF should have the same link text.';
|
36 | exports.DIFFERENT_HREF_DIFFERENT_TEXT_FAILURE_STRING = 'Links that point to different HREFs should have different link text.';
|
37 | exports.ACCESSIBLE_HIDDEN_CONTENT_FAILURE_STRING = 'Link content can not be hidden for screen-readers by using aria-hidden attribute.';
|
38 | var Rule = (function (_super) {
|
39 | __extends(Rule, _super);
|
40 | function Rule() {
|
41 | return _super !== null && _super.apply(this, arguments) || this;
|
42 | }
|
43 | Rule.prototype.apply = function (sourceFile) {
|
44 | if (sourceFile.languageVariant === ts.LanguageVariant.JSX) {
|
45 | return this.applyWithFunction(sourceFile, walk, this.parseOptions(this.getOptions()));
|
46 | }
|
47 | return [];
|
48 | };
|
49 | Rule.prototype.parseOptions = function (options) {
|
50 | var parsed = {
|
51 | ignoreCase: false,
|
52 | ignoreWhitespace: ''
|
53 | };
|
54 | options.ruleArguments.forEach(function (opt) {
|
55 | if (typeof opt === 'string' && opt === exports.OPTION_IGNORE_CASE) {
|
56 | parsed.ignoreCase = true;
|
57 | }
|
58 | if (TypeGuard_1.isObject(opt)) {
|
59 | parsed.ignoreWhitespace = opt[exports.OPTION_IGNORE_WHITESPACE];
|
60 | }
|
61 | });
|
62 | return parsed;
|
63 | };
|
64 | Rule.metadata = {
|
65 | ruleName: 'react-a11y-anchors',
|
66 | type: 'functionality',
|
67 | description: 'For accessibility of your website, anchor elements must have a href different from # and a text longer than 4.',
|
68 | rationale: "References:\n <ul>\n <li><a href=\"http://oaa-accessibility.org/wcag20/rule/38\">WCAG Rule 38: Link text should be as least four 4 characters long</a></li>\n <li><a href=\"http://oaa-accessibility.org/wcag20/rule/39\">WCAG Rule 39: Links with the same HREF should have the same link text</a></li>\n <li><a href=\"http://oaa-accessibility.org/wcag20/rule/41\">WCAG Rule 41: Links that point to different HREFs should have different link text</a></li>\n <li><a href=\"http://oaa-accessibility.org/wcag20/rule/43\">WCAG Rule 43: Links with images and text content, the alt attribute should be unique to the text content or empty</a></li>\n </ul>",
|
69 | options: {
|
70 | type: 'array',
|
71 | items: {
|
72 | type: 'string',
|
73 | enum: [exports.OPTION_IGNORE_CASE, exports.OPTION_IGNORE_WHITESPACE]
|
74 | },
|
75 | minLength: 0,
|
76 | maxLength: 2
|
77 | },
|
78 | optionsDescription: Lint.Utils.dedent(templateObject_1 || (templateObject_1 = __makeTemplateObject(["\n Optional arguments to relax the same HREF same link text rule are provided:\n * `", "` ignore differences in cases.\n * `{\"", "\": \"trim\"}` ignore differences only in leading/trailing whitespace.\n * `{\"", "\": \"all\"}` ignore differences in all whitespace.\n "], ["\n Optional arguments to relax the same HREF same link text rule are provided:\n * \\`", "\\` ignore differences in cases.\n * \\`{\"", "\": \"trim\"}\\` ignore differences only in leading/trailing whitespace.\n * \\`{\"", "\": \"all\"}\\` ignore differences in all whitespace.\n "])), exports.OPTION_IGNORE_CASE, exports.OPTION_IGNORE_WHITESPACE, exports.OPTION_IGNORE_WHITESPACE),
|
79 | typescriptOnly: true,
|
80 | issueClass: 'Non-SDL',
|
81 | issueType: 'Warning',
|
82 | severity: 'Low',
|
83 | level: 'Opportunity for Excellence',
|
84 | group: 'Accessibility'
|
85 | };
|
86 | return Rule;
|
87 | }(Lint.Rules.AbstractRule));
|
88 | exports.Rule = Rule;
|
89 | function walk(ctx) {
|
90 | var anchorInfoList = [];
|
91 | function validateAllAnchors() {
|
92 | var sameHrefDifferentTexts = [];
|
93 | var differentHrefSameText = [];
|
94 | var _loop_1 = function () {
|
95 | var current = anchorInfoList.shift();
|
96 | anchorInfoList.forEach(function (anchorInfo) {
|
97 | if (current.href &&
|
98 | current.href === anchorInfo.href &&
|
99 | !compareAnchorsText(current, anchorInfo) &&
|
100 | !Utils_1.Utils.contains(sameHrefDifferentTexts, anchorInfo)) {
|
101 | sameHrefDifferentTexts.push(anchorInfo);
|
102 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.SAME_HREF_SAME_TEXT_FAILURE_STRING + firstPosition(current));
|
103 | }
|
104 | if (current.href !== anchorInfo.href &&
|
105 | compareAnchorsText(current, anchorInfo) &&
|
106 | !Utils_1.Utils.contains(differentHrefSameText, anchorInfo)) {
|
107 | differentHrefSameText.push(anchorInfo);
|
108 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.DIFFERENT_HREF_DIFFERENT_TEXT_FAILURE_STRING + firstPosition(current));
|
109 | }
|
110 | });
|
111 | };
|
112 | while (anchorInfoList.length > 0) {
|
113 | _loop_1();
|
114 | }
|
115 | }
|
116 | function compareAnchorsText(anchor1, anchor2) {
|
117 | var text1 = anchor1.text;
|
118 | var text2 = anchor2.text;
|
119 | var altText1 = anchor1.altText;
|
120 | var altText2 = anchor2.altText;
|
121 | if (ctx.options.ignoreCase) {
|
122 | text1 = text1.toLowerCase();
|
123 | text2 = text2.toLowerCase();
|
124 | altText1 = altText1.toLowerCase();
|
125 | altText2 = altText2.toLowerCase();
|
126 | }
|
127 | if (ctx.options.ignoreWhitespace === 'trim') {
|
128 | text1 = text1.trim();
|
129 | text2 = text2.trim();
|
130 | altText1 = altText1.trim();
|
131 | altText2 = altText2.trim();
|
132 | }
|
133 | if (ctx.options.ignoreWhitespace === 'all') {
|
134 | var regex = /\s/g;
|
135 | text1 = text1.replace(regex, '');
|
136 | text2 = text2.replace(regex, '');
|
137 | altText1 = altText1.replace(regex, '');
|
138 | altText2 = altText2.replace(regex, '');
|
139 | }
|
140 | return text1 === text2 && altText1 === altText2;
|
141 | }
|
142 | function firstPosition(anchorInfo) {
|
143 | var startPosition = ctx.sourceFile.getLineAndCharacterOfPosition(Math.min(anchorInfo.start, ctx.sourceFile.end));
|
144 | var character = startPosition.character + 1;
|
145 | var line = startPosition.line + 1;
|
146 | return " First link at character: " + character + " line: " + line;
|
147 | }
|
148 | function validateAnchor(parent, openingElement) {
|
149 | if (openingElement.tagName.getText() === 'a') {
|
150 | var hrefAttribute = getAttribute(openingElement, 'href');
|
151 | var anchorInfo = {
|
152 | href: hrefAttribute ? JsxAttribute_1.getStringLiteral(hrefAttribute) || '' : '',
|
153 | text: anchorText(parent),
|
154 | altText: imageAlt(parent),
|
155 | hasAriaHiddenCount: jsxElementAriaHidden(parent),
|
156 | start: parent.getStart(),
|
157 | width: parent.getWidth()
|
158 | };
|
159 | if (JsxAttribute_1.isEmpty(hrefAttribute)) {
|
160 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.MISSING_HREF_FAILURE_STRING);
|
161 | }
|
162 | if (anchorInfo.href === '#') {
|
163 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.NO_HASH_FAILURE_STRING);
|
164 | }
|
165 | if (anchorInfo.hasAriaHiddenCount > 0) {
|
166 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.ACCESSIBLE_HIDDEN_CONTENT_FAILURE_STRING);
|
167 | }
|
168 | if (anchorInfo.altText && anchorInfo.altText === anchorInfo.text) {
|
169 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.UNIQUE_ALT_FAILURE_STRING);
|
170 | }
|
171 | var anchorInfoTextLength = anchorInfo.text ? anchorInfo.text.length : 0;
|
172 | var anchorImageAltTextLength = anchorInfo.altText ? anchorInfo.altText.length : 0;
|
173 | if (anchorRole(openingElement) === 'link' && anchorInfoTextLength < 4 && anchorImageAltTextLength < 4) {
|
174 | ctx.addFailureAt(anchorInfo.start, anchorInfo.width, exports.LINK_TEXT_TOO_SHORT_FAILURE_STRING);
|
175 | }
|
176 | anchorInfoList.push(anchorInfo);
|
177 | }
|
178 | }
|
179 | function getAttribute(openingElement, attributeName) {
|
180 | var attributes = JsxAttribute_1.getJsxAttributesFromJsxElement(openingElement);
|
181 | return attributes[attributeName];
|
182 | }
|
183 | function anchorText(root, isChild) {
|
184 | if (isChild === void 0) { isChild = false; }
|
185 | var title = '';
|
186 | if (root === undefined) {
|
187 | return title;
|
188 | }
|
189 | else if (root.kind === ts.SyntaxKind.JsxElement) {
|
190 | var jsxElement = root;
|
191 | jsxElement.children.forEach(function (child) {
|
192 | title += anchorText(child, true);
|
193 | });
|
194 | }
|
195 | else if (root.kind === ts.SyntaxKind.JsxText) {
|
196 | var jsxText = root;
|
197 | title += jsxText.getText();
|
198 | }
|
199 | else if (root.kind === ts.SyntaxKind.StringLiteral) {
|
200 | var literal = root;
|
201 | title += literal.text;
|
202 | }
|
203 | else if (root.kind === ts.SyntaxKind.JsxExpression) {
|
204 | var expression = root;
|
205 | title += anchorText(expression.expression);
|
206 | }
|
207 | else if (isChild && root.kind === ts.SyntaxKind.JsxSelfClosingElement) {
|
208 | var jsxSelfClosingElement = root;
|
209 | if (jsxSelfClosingElement.tagName.getText() !== 'img') {
|
210 | title += '<component>';
|
211 | }
|
212 | }
|
213 | else if (root.kind !== ts.SyntaxKind.JsxSelfClosingElement) {
|
214 | title += '<unknown>';
|
215 | }
|
216 | return title;
|
217 | }
|
218 | function anchorRole(root) {
|
219 | var attributesInElement = JsxAttribute_1.getJsxAttributesFromJsxElement(root);
|
220 | var roleProp = attributesInElement[ROLE_STRING];
|
221 | return roleProp ? JsxAttribute_1.getStringLiteral(roleProp) : getImplicitRole_1.getImplicitRole(root);
|
222 | }
|
223 | function imageAltAttribute(openingElement) {
|
224 | if (openingElement.tagName.getText() === 'img') {
|
225 | var altAttribute = JsxAttribute_1.getStringLiteral(getAttribute(openingElement, 'alt'));
|
226 | return altAttribute === undefined ? '<unknown>' : altAttribute;
|
227 | }
|
228 | return '';
|
229 | }
|
230 | function imageAlt(root) {
|
231 | var altText = '';
|
232 | if (root.kind === ts.SyntaxKind.JsxElement) {
|
233 | var jsxElement = root;
|
234 | altText += imageAltAttribute(jsxElement.openingElement);
|
235 | jsxElement.children.forEach(function (child) {
|
236 | altText += imageAlt(child);
|
237 | });
|
238 | }
|
239 | if (root.kind === ts.SyntaxKind.JsxSelfClosingElement) {
|
240 | var jsxSelfClosingElement = root;
|
241 | altText += imageAltAttribute(jsxSelfClosingElement);
|
242 | }
|
243 | return altText;
|
244 | }
|
245 | function ariaHiddenAttribute(openingElement) {
|
246 | return getAttribute(openingElement, 'aria-hidden') === undefined;
|
247 | }
|
248 | function jsxElementAriaHidden(root) {
|
249 | var hasAriaHiddenCount = 0;
|
250 | if (root.kind === ts.SyntaxKind.JsxElement) {
|
251 | var jsxElement = root;
|
252 | hasAriaHiddenCount += ariaHiddenAttribute(jsxElement.openingElement) ? 0 : 1;
|
253 | jsxElement.children.forEach(function (child) {
|
254 | hasAriaHiddenCount += jsxElementAriaHidden(child);
|
255 | });
|
256 | }
|
257 | if (root.kind === ts.SyntaxKind.JsxSelfClosingElement) {
|
258 | var jsxSelfClosingElement = root;
|
259 | hasAriaHiddenCount += ariaHiddenAttribute(jsxSelfClosingElement) ? 0 : 1;
|
260 | }
|
261 | return hasAriaHiddenCount;
|
262 | }
|
263 | function cb(node) {
|
264 | if (tsutils.isJsxSelfClosingElement(node)) {
|
265 | validateAnchor(node, node);
|
266 | }
|
267 | else if (tsutils.isJsxElement(node)) {
|
268 | validateAnchor(node, node.openingElement);
|
269 | }
|
270 | return ts.forEachChild(node, cb);
|
271 | }
|
272 | ts.forEachChild(ctx.sourceFile, cb);
|
273 | validateAllAnchors();
|
274 | }
|
275 | var templateObject_1;
|
276 |
|
\ | No newline at end of file |