source: imaps-frontend/node_modules/eslint-plugin-react/lib/rules/no-invalid-html-attribute.js@ d565449

main
Last change on this file since d565449 was d565449, checked in by stefan toskovski <stefantoska84@…>, 4 weeks ago

Update repo after prototype presentation

  • Property mode set to 100644
File size: 16.2 KB
RevLine 
[d565449]1/**
2 * @fileoverview Check if tag attributes to have non-valid value
3 * @author Sebastian Malton
4 */
5
6'use strict';
7
8const matchAll = require('string.prototype.matchall');
9const docsUrl = require('../util/docsUrl');
10const report = require('../util/report');
11
12// ------------------------------------------------------------------------------
13// Rule Definition
14// ------------------------------------------------------------------------------
15
16const rel = new Map([
17 ['alternate', new Set(['link', 'area', 'a'])],
18 ['apple-touch-icon', new Set(['link'])],
19 ['apple-touch-startup-image', new Set(['link'])],
20 ['author', new Set(['link', 'area', 'a'])],
21 ['bookmark', new Set(['area', 'a'])],
22 ['canonical', new Set(['link'])],
23 ['dns-prefetch', new Set(['link'])],
24 ['external', new Set(['area', 'a', 'form'])],
25 ['help', new Set(['link', 'area', 'a', 'form'])],
26 ['icon', new Set(['link'])],
27 ['license', new Set(['link', 'area', 'a', 'form'])],
28 ['manifest', new Set(['link'])],
29 ['mask-icon', new Set(['link'])],
30 ['modulepreload', new Set(['link'])],
31 ['next', new Set(['link', 'area', 'a', 'form'])],
32 ['nofollow', new Set(['area', 'a', 'form'])],
33 ['noopener', new Set(['area', 'a', 'form'])],
34 ['noreferrer', new Set(['area', 'a', 'form'])],
35 ['opener', new Set(['area', 'a', 'form'])],
36 ['pingback', new Set(['link'])],
37 ['preconnect', new Set(['link'])],
38 ['prefetch', new Set(['link'])],
39 ['preload', new Set(['link'])],
40 ['prerender', new Set(['link'])],
41 ['prev', new Set(['link', 'area', 'a', 'form'])],
42 ['search', new Set(['link', 'area', 'a', 'form'])],
43 ['shortcut', new Set(['link'])], // generally allowed but needs pair with "icon"
44 ['shortcut\u0020icon', new Set(['link'])],
45 ['stylesheet', new Set(['link'])],
46 ['tag', new Set(['area', 'a'])],
47]);
48
49const pairs = new Map([
50 ['shortcut', new Set(['icon'])],
51]);
52
53/**
54 * Map between attributes and a mapping between valid values and a set of tags they are valid on
55 * @type {Map<string, Map<string, Set<string>>>}
56 */
57const VALID_VALUES = new Map([
58 ['rel', rel],
59]);
60
61/**
62 * Map between attributes and a mapping between pair-values and a set of values they are valid with
63 * @type {Map<string, Map<string, Set<string>>>}
64 */
65const VALID_PAIR_VALUES = new Map([
66 ['rel', pairs],
67]);
68
69/**
70 * The set of all possible HTML elements. Used for skipping custom types
71 * @type {Set<string>}
72 */
73const HTML_ELEMENTS = new Set([
74 'a',
75 'abbr',
76 'acronym',
77 'address',
78 'applet',
79 'area',
80 'article',
81 'aside',
82 'audio',
83 'b',
84 'base',
85 'basefont',
86 'bdi',
87 'bdo',
88 'bgsound',
89 'big',
90 'blink',
91 'blockquote',
92 'body',
93 'br',
94 'button',
95 'canvas',
96 'caption',
97 'center',
98 'cite',
99 'code',
100 'col',
101 'colgroup',
102 'content',
103 'data',
104 'datalist',
105 'dd',
106 'del',
107 'details',
108 'dfn',
109 'dialog',
110 'dir',
111 'div',
112 'dl',
113 'dt',
114 'em',
115 'embed',
116 'fieldset',
117 'figcaption',
118 'figure',
119 'font',
120 'footer',
121 'form',
122 'frame',
123 'frameset',
124 'h1',
125 'h2',
126 'h3',
127 'h4',
128 'h5',
129 'h6',
130 'head',
131 'header',
132 'hgroup',
133 'hr',
134 'html',
135 'i',
136 'iframe',
137 'image',
138 'img',
139 'input',
140 'ins',
141 'kbd',
142 'keygen',
143 'label',
144 'legend',
145 'li',
146 'link',
147 'main',
148 'map',
149 'mark',
150 'marquee',
151 'math',
152 'menu',
153 'menuitem',
154 'meta',
155 'meter',
156 'nav',
157 'nobr',
158 'noembed',
159 'noframes',
160 'noscript',
161 'object',
162 'ol',
163 'optgroup',
164 'option',
165 'output',
166 'p',
167 'param',
168 'picture',
169 'plaintext',
170 'portal',
171 'pre',
172 'progress',
173 'q',
174 'rb',
175 'rp',
176 'rt',
177 'rtc',
178 'ruby',
179 's',
180 'samp',
181 'script',
182 'section',
183 'select',
184 'shadow',
185 'slot',
186 'small',
187 'source',
188 'spacer',
189 'span',
190 'strike',
191 'strong',
192 'style',
193 'sub',
194 'summary',
195 'sup',
196 'svg',
197 'table',
198 'tbody',
199 'td',
200 'template',
201 'textarea',
202 'tfoot',
203 'th',
204 'thead',
205 'time',
206 'title',
207 'tr',
208 'track',
209 'tt',
210 'u',
211 'ul',
212 'var',
213 'video',
214 'wbr',
215 'xmp',
216]);
217
218/**
219* Map between attributes and set of tags that the attribute is valid on
220* @type {Map<string, Set<string>>}
221*/
222const COMPONENT_ATTRIBUTE_MAP = new Map([
223 ['rel', new Set(['link', 'a', 'area', 'form'])],
224]);
225
226/* eslint-disable eslint-plugin/no-unused-message-ids -- false positives, these messageIds are used */
227const messages = {
228 emptyIsMeaningless: 'An empty “{{attributeName}}” attribute is meaningless.',
229 neverValid: '“{{reportingValue}}” is never a valid “{{attributeName}}” attribute value.',
230 noEmpty: 'An empty “{{attributeName}}” attribute is meaningless.',
231 noMethod: 'The ”{{attributeName}}“ attribute cannot be a method.',
232 notAlone: '“{{reportingValue}}” must be directly followed by “{{missingValue}}”.',
233 notPaired: '“{{reportingValue}}” can not be directly followed by “{{secondValue}}” without “{{missingValue}}”.',
234 notValidFor: '“{{reportingValue}}” is not a valid “{{attributeName}}” attribute value for <{{elementName}}>.',
235 onlyMeaningfulFor: 'The ”{{attributeName}}“ attribute only has meaning on the tags: {{tagNames}}',
236 onlyStrings: '“{{attributeName}}” attribute only supports strings.',
237 spaceDelimited: '”{{attributeName}}“ attribute values should be space delimited.',
238 suggestRemoveDefault: '"remove {{attributeName}}"',
239 suggestRemoveEmpty: '"remove empty attribute {{attributeName}}"',
240 suggestRemoveInvalid: '“remove invalid attribute {{reportingValue}}”',
241 suggestRemoveWhitespaces: 'remove whitespaces in “{{attributeName}}”',
242 suggestRemoveNonString: 'remove non-string value in “{{attributeName}}”',
243};
244
245function splitIntoRangedParts(node, regex) {
246 const valueRangeStart = node.range[0] + 1; // the plus one is for the initial quote
247
248 return Array.from(matchAll(node.value, regex), (match) => {
249 const start = match.index + valueRangeStart;
250 const end = start + match[0].length;
251
252 return {
253 reportingValue: `${match[1]}`,
254 value: match[1],
255 range: [start, end],
256 };
257 });
258}
259
260function checkLiteralValueNode(context, attributeName, node, parentNode, parentNodeName) {
261 if (typeof node.value !== 'string') {
262 const data = { attributeName, reportingValue: node.value };
263
264 report(context, messages.onlyStrings, 'onlyStrings', {
265 node,
266 data,
267 suggest: [{
268 messageId: 'suggestRemoveNonString',
269 data,
270 fix(fixer) { return fixer.remove(parentNode); },
271 }],
272 });
273 return;
274 }
275
276 if (!node.value.trim()) {
277 const data = { attributeName, reportingValue: node.value };
278
279 report(context, messages.noEmpty, 'noEmpty', {
280 node,
281 data,
282 suggest: [{
283 messageId: 'suggestRemoveEmpty',
284 data,
285 fix(fixer) { return fixer.remove(node.parent); },
286 }],
287 });
288 return;
289 }
290
291 const singleAttributeParts = splitIntoRangedParts(node, /(\S+)/g);
292 for (const singlePart of singleAttributeParts) {
293 const allowedTags = VALID_VALUES.get(attributeName).get(singlePart.value);
294 const reportingValue = singlePart.reportingValue;
295
296 if (!allowedTags) {
297 const data = {
298 attributeName,
299 reportingValue,
300 };
301
302 const suggest = [{
303 messageId: 'suggestRemoveInvalid',
304 data,
305 fix(fixer) { return fixer.removeRange(singlePart.range); },
306 }];
307
308 report(context, messages.neverValid, 'neverValid', {
309 node,
310 data,
311 suggest,
312 });
313 } else if (!allowedTags.has(parentNodeName)) {
314 const data = {
315 attributeName,
316 reportingValue,
317 elementName: parentNodeName,
318 };
319
320 const suggest = [{
321 messageId: 'suggestRemoveInvalid',
322 data,
323 fix(fixer) { return fixer.removeRange(singlePart.range); },
324 }];
325
326 report(context, messages.notValidFor, 'notValidFor', {
327 node,
328 data,
329 suggest,
330 });
331 }
332 }
333
334 const allowedPairsForAttribute = VALID_PAIR_VALUES.get(attributeName);
335 if (allowedPairsForAttribute) {
336 const pairAttributeParts = splitIntoRangedParts(node, /(?=(\b\S+\s*\S+))/g);
337 for (const pairPart of pairAttributeParts) {
338 for (const allowedPair of allowedPairsForAttribute) {
339 const pairing = allowedPair[0];
340 const siblings = allowedPair[1];
341 const attributes = pairPart.reportingValue.split('\u0020');
342 const firstValue = attributes[0];
343 const secondValue = attributes[1];
344 if (firstValue === pairing) {
345 const lastValue = attributes[attributes.length - 1]; // in case of multiple white spaces
346 if (!siblings.has(lastValue)) {
347 const message = secondValue ? messages.notPaired : messages.notAlone;
348 const messageId = secondValue ? 'notPaired' : 'notAlone';
349 report(context, message, messageId, {
350 node,
351 data: {
352 reportingValue: firstValue,
353 secondValue,
354 missingValue: Array.from(siblings).join(', '),
355 },
356 suggest: false,
357 });
358 }
359 }
360 }
361 }
362 }
363
364 const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
365 for (const whitespacePart of whitespaceParts) {
366 const data = { attributeName };
367
368 if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
369 report(context, messages.spaceDelimited, 'spaceDelimited', {
370 node,
371 data,
372 suggest: [{
373 messageId: 'suggestRemoveWhitespaces',
374 data,
375 fix(fixer) { return fixer.removeRange(whitespacePart.range); },
376 }],
377 });
378 } else if (whitespacePart.value !== '\u0020') {
379 report(context, messages.spaceDelimited, 'spaceDelimited', {
380 node,
381 data,
382 suggest: [{
383 messageId: 'suggestRemoveWhitespaces',
384 data,
385 fix(fixer) { return fixer.replaceTextRange(whitespacePart.range, '\u0020'); },
386 }],
387 });
388 }
389 }
390}
391
392const DEFAULT_ATTRIBUTES = ['rel'];
393
394function checkAttribute(context, node) {
395 const attribute = node.name.name;
396
397 const parentNodeName = node.parent.name.name;
398 if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
399 const tagNames = Array.from(
400 COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
401 (tagName) => `"<${tagName}>"`
402 ).join(', ');
403 const data = {
404 attributeName: attribute,
405 tagNames,
406 };
407
408 report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
409 node: node.name,
410 data,
411 suggest: [{
412 messageId: 'suggestRemoveDefault',
413 data,
414 fix(fixer) { return fixer.remove(node); },
415 }],
416 });
417 return;
418 }
419
420 function fix(fixer) { return fixer.remove(node); }
421
422 if (!node.value) {
423 const data = { attributeName: attribute };
424
425 report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
426 node: node.name,
427 data,
428 suggest: [{
429 messageId: 'suggestRemoveEmpty',
430 data,
431 fix,
432 }],
433 });
434 return;
435 }
436
437 if (node.value.type === 'Literal') {
438 return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
439 }
440
441 if (node.value.expression.type === 'Literal') {
442 return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
443 }
444
445 if (node.value.type !== 'JSXExpressionContainer') {
446 return;
447 }
448
449 if (node.value.expression.type === 'ObjectExpression') {
450 const data = { attributeName: attribute };
451
452 report(context, messages.onlyStrings, 'onlyStrings', {
453 node: node.value,
454 data,
455 suggest: [{
456 messageId: 'suggestRemoveDefault',
457 data,
458 fix,
459 }],
460 });
461 } else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
462 const data = { attributeName: attribute };
463
464 report(context, messages.onlyStrings, 'onlyStrings', {
465 node: node.value,
466 data,
467 suggest: [{
468 messageId: 'suggestRemoveDefault',
469 data,
470 fix,
471 }],
472 });
473 }
474}
475
476function isValidCreateElement(node) {
477 return node.callee
478 && node.callee.type === 'MemberExpression'
479 && node.callee.object.name === 'React'
480 && node.callee.property.name === 'createElement'
481 && node.arguments.length > 0;
482}
483
484function checkPropValidValue(context, node, value, attribute) {
485 const validTags = VALID_VALUES.get(attribute);
486
487 if (value.type !== 'Literal') {
488 return; // cannot check non-literals
489 }
490
491 const validTagSet = validTags.get(value.value);
492 if (!validTagSet) {
493 const data = {
494 attributeName: attribute,
495 reportingValue: value.value,
496 };
497
498 report(context, messages.neverValid, 'neverValid', {
499 node: value,
500 data,
501 suggest: [{
502 messageId: 'suggestRemoveInvalid',
503 data,
504 fix(fixer) { return fixer.replaceText(value, value.raw.replace(value.value, '')); },
505 }],
506 });
507 } else if (!validTagSet.has(node.arguments[0].value)) {
508 report(context, messages.notValidFor, 'notValidFor', {
509 node: value,
510 data: {
511 attributeName: attribute,
512 reportingValue: value.raw,
513 elementName: node.arguments[0].value,
514 },
515 suggest: false,
516 });
517 }
518}
519
520/**
521 *
522 * @param {*} context
523 * @param {*} node
524 * @param {string} attribute
525 */
526function checkCreateProps(context, node, attribute) {
527 const propsArg = node.arguments[1];
528
529 if (!propsArg || propsArg.type !== 'ObjectExpression') {
530 return; // can't check variables, computed, or shorthands
531 }
532
533 for (const prop of propsArg.properties) {
534 if (!prop.key || prop.key.type !== 'Identifier') {
535 // eslint-disable-next-line no-continue
536 continue; // cannot check computed keys
537 }
538
539 if (prop.key.name !== attribute) {
540 // eslint-disable-next-line no-continue
541 continue; // ignore not this attribute
542 }
543
544 if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) {
545 const tagNames = Array.from(
546 COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
547 (tagName) => `"<${tagName}>"`
548 ).join(', ');
549
550 report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
551 node: prop.key,
552 data: {
553 attributeName: attribute,
554 tagNames,
555 },
556 suggest: false,
557 });
558
559 // eslint-disable-next-line no-continue
560 continue;
561 }
562
563 if (prop.method) {
564 report(context, messages.noMethod, 'noMethod', {
565 node: prop,
566 data: {
567 attributeName: attribute,
568 },
569 suggest: false,
570 });
571
572 // eslint-disable-next-line no-continue
573 continue;
574 }
575
576 if (prop.shorthand || prop.computed) {
577 // eslint-disable-next-line no-continue
578 continue; // cannot check these
579 }
580
581 if (prop.value.type === 'ArrayExpression') {
582 for (const value of prop.value.elements) {
583 checkPropValidValue(context, node, value, attribute);
584 }
585
586 // eslint-disable-next-line no-continue
587 continue;
588 }
589
590 checkPropValidValue(context, node, prop.value, attribute);
591 }
592}
593
594module.exports = {
595 meta: {
596 docs: {
597 description: 'Disallow usage of invalid attributes',
598 category: 'Possible Errors',
599 url: docsUrl('no-invalid-html-attribute'),
600 },
601 messages,
602 schema: [{
603 type: 'array',
604 uniqueItems: true,
605 items: {
606 enum: ['rel'],
607 },
608 }],
609 type: 'suggestion',
610 hasSuggestions: true, // eslint-disable-line eslint-plugin/require-meta-has-suggestions
611 },
612
613 create(context) {
614 return {
615 JSXAttribute(node) {
616 const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
617
618 // ignore attributes that aren't configured to be checked
619 if (!attributes.has(node.name.name)) {
620 return;
621 }
622
623 // ignore non-HTML elements
624 if (!HTML_ELEMENTS.has(node.parent.name.name)) {
625 return;
626 }
627
628 checkAttribute(context, node);
629 },
630
631 CallExpression(node) {
632 if (!isValidCreateElement(node)) {
633 return;
634 }
635
636 const elemNameArg = node.arguments[0];
637
638 if (!elemNameArg || elemNameArg.type !== 'Literal') {
639 return; // can only check literals
640 }
641
642 // ignore non-HTML elements
643 if (!HTML_ELEMENTS.has(elemNameArg.value)) {
644 return;
645 }
646
647 const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
648
649 for (const attribute of attributes) {
650 checkCreateProps(context, node, attribute);
651 }
652 },
653 };
654 },
655};
Note: See TracBrowser for help on using the repository browser.