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

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

Pred finalna verzija

  • Property mode set to 100644
File size: 16.2 KB
Line 
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 singleAttributeParts.forEach((singlePart) => {
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 pairAttributeParts.forEach((pairPart) => {
338 allowedPairsForAttribute.forEach((siblings, pairing) => {
339 const attributes = pairPart.reportingValue.split('\u0020');
340 const firstValue = attributes[0];
341 const secondValue = attributes[1];
342 if (firstValue === pairing) {
343 const lastValue = attributes[attributes.length - 1]; // in case of multiple white spaces
344 if (!siblings.has(lastValue)) {
345 const message = secondValue ? messages.notPaired : messages.notAlone;
346 const messageId = secondValue ? 'notPaired' : 'notAlone';
347 report(context, message, messageId, {
348 node,
349 data: {
350 reportingValue: firstValue,
351 secondValue,
352 missingValue: Array.from(siblings).join(', '),
353 },
354 suggest: false,
355 });
356 }
357 }
358 });
359 });
360 }
361
362 const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
363 whitespaceParts.forEach((whitespacePart) => {
364 const data = { attributeName };
365
366 if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
367 report(context, messages.spaceDelimited, 'spaceDelimited', {
368 node,
369 data,
370 suggest: [{
371 messageId: 'suggestRemoveWhitespaces',
372 data,
373 fix(fixer) { return fixer.removeRange(whitespacePart.range); },
374 }],
375 });
376 } else if (whitespacePart.value !== '\u0020') {
377 report(context, messages.spaceDelimited, 'spaceDelimited', {
378 node,
379 data,
380 suggest: [{
381 messageId: 'suggestRemoveWhitespaces',
382 data,
383 fix(fixer) { return fixer.replaceTextRange(whitespacePart.range, '\u0020'); },
384 }],
385 });
386 }
387 });
388}
389
390const DEFAULT_ATTRIBUTES = ['rel'];
391
392function checkAttribute(context, node) {
393 const attribute = node.name.name;
394
395 const parentNodeName = node.parent.name.name;
396 if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
397 const tagNames = Array.from(
398 COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
399 (tagName) => `"<${tagName}>"`
400 ).join(', ');
401 const data = {
402 attributeName: attribute,
403 tagNames,
404 };
405
406 report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
407 node: node.name,
408 data,
409 suggest: [{
410 messageId: 'suggestRemoveDefault',
411 data,
412 fix(fixer) { return fixer.remove(node); },
413 }],
414 });
415 return;
416 }
417
418 function fix(fixer) { return fixer.remove(node); }
419
420 if (!node.value) {
421 const data = { attributeName: attribute };
422
423 report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
424 node: node.name,
425 data,
426 suggest: [{
427 messageId: 'suggestRemoveEmpty',
428 data,
429 fix,
430 }],
431 });
432 return;
433 }
434
435 if (node.value.type === 'Literal') {
436 return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
437 }
438
439 if (node.value.expression.type === 'Literal') {
440 return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
441 }
442
443 if (node.value.type !== 'JSXExpressionContainer') {
444 return;
445 }
446
447 if (node.value.expression.type === 'ObjectExpression') {
448 const data = { attributeName: attribute };
449
450 report(context, messages.onlyStrings, 'onlyStrings', {
451 node: node.value,
452 data,
453 suggest: [{
454 messageId: 'suggestRemoveDefault',
455 data,
456 fix,
457 }],
458 });
459 } else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
460 const data = { attributeName: attribute };
461
462 report(context, messages.onlyStrings, 'onlyStrings', {
463 node: node.value,
464 data,
465 suggest: [{
466 messageId: 'suggestRemoveDefault',
467 data,
468 fix,
469 }],
470 });
471 }
472}
473
474function isValidCreateElement(node) {
475 return node.callee
476 && node.callee.type === 'MemberExpression'
477 && node.callee.object.name === 'React'
478 && node.callee.property.name === 'createElement'
479 && node.arguments.length > 0;
480}
481
482function checkPropValidValue(context, node, value, attribute) {
483 const validTags = VALID_VALUES.get(attribute);
484
485 if (value.type !== 'Literal') {
486 return; // cannot check non-literals
487 }
488
489 const validTagSet = validTags.get(value.value);
490 if (!validTagSet) {
491 const data = {
492 attributeName: attribute,
493 reportingValue: value.value,
494 };
495
496 report(context, messages.neverValid, 'neverValid', {
497 node: value,
498 data,
499 suggest: [{
500 messageId: 'suggestRemoveInvalid',
501 data,
502 fix(fixer) { return fixer.replaceText(value, value.raw.replace(value.value, '')); },
503 }],
504 });
505 } else if (!validTagSet.has(node.arguments[0].value)) {
506 report(context, messages.notValidFor, 'notValidFor', {
507 node: value,
508 data: {
509 attributeName: attribute,
510 reportingValue: value.raw,
511 elementName: node.arguments[0].value,
512 },
513 suggest: false,
514 });
515 }
516}
517
518/**
519 *
520 * @param {*} context
521 * @param {*} node
522 * @param {string} attribute
523 */
524function checkCreateProps(context, node, attribute) {
525 const propsArg = node.arguments[1];
526
527 if (!propsArg || propsArg.type !== 'ObjectExpression') {
528 return; // can't check variables, computed, or shorthands
529 }
530
531 for (const prop of propsArg.properties) {
532 if (!prop.key || prop.key.type !== 'Identifier') {
533 // eslint-disable-next-line no-continue
534 continue; // cannot check computed keys
535 }
536
537 if (prop.key.name !== attribute) {
538 // eslint-disable-next-line no-continue
539 continue; // ignore not this attribute
540 }
541
542 if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) {
543 const tagNames = Array.from(
544 COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
545 (tagName) => `"<${tagName}>"`
546 ).join(', ');
547
548 report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
549 node: prop.key,
550 data: {
551 attributeName: attribute,
552 tagNames,
553 },
554 suggest: false,
555 });
556
557 // eslint-disable-next-line no-continue
558 continue;
559 }
560
561 if (prop.method) {
562 report(context, messages.noMethod, 'noMethod', {
563 node: prop,
564 data: {
565 attributeName: attribute,
566 },
567 suggest: false,
568 });
569
570 // eslint-disable-next-line no-continue
571 continue;
572 }
573
574 if (prop.shorthand || prop.computed) {
575 // eslint-disable-next-line no-continue
576 continue; // cannot check these
577 }
578
579 if (prop.value.type === 'ArrayExpression') {
580 prop.value.elements.forEach((value) => {
581 checkPropValidValue(context, node, value, attribute);
582 });
583
584 // eslint-disable-next-line no-continue
585 continue;
586 }
587
588 checkPropValidValue(context, node, prop.value, attribute);
589 }
590}
591
592/** @type {import('eslint').Rule.RuleModule} */
593module.exports = {
594 meta: {
595 docs: {
596 description: 'Disallow usage of invalid attributes',
597 category: 'Possible Errors',
598 url: docsUrl('no-invalid-html-attribute'),
599 },
600 messages,
601 schema: [{
602 type: 'array',
603 uniqueItems: true,
604 items: {
605 enum: ['rel'],
606 },
607 }],
608 type: 'suggestion',
609 hasSuggestions: true, // eslint-disable-line eslint-plugin/require-meta-has-suggestions
610 },
611
612 create(context) {
613 return {
614 JSXAttribute(node) {
615 const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
616
617 // ignore attributes that aren't configured to be checked
618 if (!attributes.has(node.name.name)) {
619 return;
620 }
621
622 // ignore non-HTML elements
623 if (!HTML_ELEMENTS.has(node.parent.name.name)) {
624 return;
625 }
626
627 checkAttribute(context, node);
628 },
629
630 CallExpression(node) {
631 if (!isValidCreateElement(node)) {
632 return;
633 }
634
635 const elemNameArg = node.arguments[0];
636
637 if (!elemNameArg || elemNameArg.type !== 'Literal') {
638 return; // can only check literals
639 }
640
641 // ignore non-HTML elements
642 if (typeof elemNameArg.value === 'string' && !HTML_ELEMENTS.has(elemNameArg.value)) {
643 return;
644 }
645
646 const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
647
648 attributes.forEach((attribute) => {
649 checkCreateProps(context, node, attribute);
650 });
651 },
652 };
653 },
654};
Note: See TracBrowser for help on using the repository browser.