UNPKG

6.18 kBJavaScriptView Raw
1const {
2 getImportDetailsForName,
3 DEFAULT_IMPORT,
4 NAMESPACE_IMPORT,
5 docsUrl,
6} = require('../utilities');
7
8const defaultDOMOptions = {
9 '*': {checkProps: ['title', 'aria-label']},
10 area: {checkProps: ['title', 'alt', 'aria-label']},
11 img: {checkProps: ['title', 'alt', 'aria-label']},
12 input: {checkProps: ['title', 'alt', 'placeholder', 'aria-label']},
13};
14
15module.exports = {
16 meta: {
17 docs: {
18 description: 'Disallow hardcoded content in JSX.',
19 category: 'Best Practices',
20 recommended: false,
21 uri: docsUrl('jsx-no-hardcoded-content'),
22 },
23 schema: [
24 {
25 type: 'object',
26 properties: {
27 allowStrings: {
28 type: 'boolean',
29 },
30 allowNumbers: {
31 type: 'boolean',
32 },
33 checkProps: {
34 type: 'array',
35 items: {type: 'string'},
36 },
37 dom: {
38 type: 'object',
39 additionalProperties: {
40 type: 'object',
41 properties: {
42 allowNumbers: {
43 type: 'boolean',
44 },
45 allowStrings: {
46 type: 'boolean',
47 },
48 checkProps: {
49 items: {
50 type: 'string',
51 },
52 type: 'array',
53 },
54 },
55 },
56 },
57 modules: {
58 type: 'object',
59 additionalProperties: {
60 type: 'object',
61 additionalProperties: {
62 type: 'object',
63 properties: {
64 allowNumbers: {
65 type: 'boolean',
66 },
67 allowStrings: {
68 type: 'boolean',
69 },
70 checkProps: {
71 items: {
72 type: 'string',
73 },
74 type: 'array',
75 },
76 },
77 },
78 },
79 },
80 },
81 additionalProperties: false,
82 },
83 ],
84 },
85
86 create(context) {
87 const defaultOptions = context.options[0] || {};
88 const modules = defaultOptions.modules || {};
89 const dom = defaultOptions.dom || {};
90 const optionsForAllDOM = dom['*'] || defaultDOMOptions['*'];
91
92 // eslint-disable-next-line prefer-object-spread
93 const defaultOptionsForUnspecifiedDOMElement = Object.assign(
94 {},
95 defaultOptions,
96 optionsForAllDOM,
97 );
98
99 function optionsForNode(node) {
100 if (isDOMElement(node)) {
101 const {name} = node.openingElement.name;
102
103 return (
104 dom[node.openingElement.name.name] ||
105 defaultDOMOptions[name] ||
106 defaultOptionsForUnspecifiedDOMElement
107 );
108 }
109
110 const importDetails = getImportDetailsForJSX(node, context);
111
112 if (importDetails == null) {
113 return defaultOptions;
114 }
115
116 const foundModule = modules[importDetails.source];
117 if (foundModule == null) {
118 return defaultOptions;
119 }
120
121 return foundModule[importDetails.name] || defaultOptions;
122 }
123
124 return {
125 JSXElement(node) {
126 const check = checkContent(node, optionsForNode(node));
127
128 if (check.valid) {
129 return;
130 }
131
132 const elementName = context
133 .getSourceCode()
134 .getText(node.openingElement.name);
135
136 if (check.prop === 'children') {
137 context.report(
138 node,
139 `Do not use hardcoded content as the children of the ${elementName} component.`,
140 );
141 } else if (check.prop) {
142 context.report(
143 node,
144 `Do not use hardcoded content in the ${check.prop} prop of the ${elementName} component.`,
145 );
146 }
147 },
148 };
149 },
150};
151
152function getImportDetailsForJSX({openingElement}, context) {
153 const isMemberExpression = openingElement.name.type === 'JSXMemberExpression';
154 const searchForName = isMemberExpression
155 ? openingElement.name.object.name
156 : openingElement.name.name;
157
158 const importDetails = getImportDetailsForName(searchForName, context);
159
160 if (importDetails == null) {
161 return null;
162 }
163
164 const {source, imported} = importDetails;
165 let normalizedImport;
166
167 if (isMemberExpression) {
168 const memberName = openingElement.name.property.name;
169
170 if (imported === DEFAULT_IMPORT) {
171 normalizedImport = `default.${memberName}`;
172 } else if (imported === NAMESPACE_IMPORT) {
173 normalizedImport = memberName;
174 } else {
175 normalizedImport = `${imported}.${memberName}`;
176 }
177 } else {
178 normalizedImport = imported === DEFAULT_IMPORT ? 'default' : imported;
179 }
180
181 return {
182 source,
183 name: normalizedImport,
184 };
185}
186
187function isEmptyString(string) {
188 return string.trim().length === 0;
189}
190
191function isDOMElement({openingElement: {name}}) {
192 return name.type === 'JSXIdentifier' && name.name === name.name.toLowerCase();
193}
194
195function checkContent(
196 node,
197 {allowStrings = false, allowNumbers = true, checkProps = []},
198) {
199 function isInvalidContent(contentNode) {
200 return (
201 (contentNode.type === 'Literal' &&
202 ((!allowStrings &&
203 typeof contentNode.value === 'string' &&
204 !isEmptyString(contentNode.value)) ||
205 (!allowNumbers && typeof contentNode.value === 'number'))) ||
206 (contentNode.type === 'TemplateLiteral' && !allowStrings) ||
207 (contentNode.type === 'JSXExpressionContainer' &&
208 isInvalidContent(contentNode.expression))
209 );
210 }
211
212 function isInvalidProp(propNode) {
213 return (
214 propNode.type === 'JSXAttribute' &&
215 checkProps.some((prop) => prop === propNode.name.name) &&
216 isInvalidContent(
217 propNode.value == null
218 ? {type: 'Literal', value: true}
219 : propNode.value,
220 )
221 );
222 }
223
224 if (node.children.some(isInvalidContent)) {
225 return {valid: false, prop: 'children'};
226 }
227
228 const invalidProp =
229 node.openingElement.attributes &&
230 node.openingElement.attributes.find(isInvalidProp);
231 return invalidProp
232 ? {valid: false, prop: invalidProp.name.name}
233 : {valid: true};
234}