1 | const {
|
2 | getImportDetailsForName,
|
3 | DEFAULT_IMPORT,
|
4 | NAMESPACE_IMPORT,
|
5 | docsUrl,
|
6 | } = require('../utilities');
|
7 |
|
8 | const 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 |
|
15 | module.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 |
|
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 |
|
152 | function 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 |
|
187 | function isEmptyString(string) {
|
188 | return string.trim().length === 0;
|
189 | }
|
190 |
|
191 | function isDOMElement({openingElement: {name}}) {
|
192 | return name.type === 'JSXIdentifier' && name.name === name.name.toLowerCase();
|
193 | }
|
194 |
|
195 | function 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 | }
|