forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1'use strict'
2
3// Partially based on eslint-plugin-react-native.
4// Portions of code by Alex Zhukov, MIT license.
5
6function hasOnlyLineBreak(value) {
7 return /^[\r\n\t\f\v]+$/.test(value.replace(/ /g, ''))
8}
9
10function getTagName(node) {
11 const reversedIdentifiers = []
12 if (
13 node.type === 'JSXElement' &&
14 node.openingElement.type === 'JSXOpeningElement'
15 ) {
16 let object = node.openingElement.name
17 while (object.type === 'JSXMemberExpression') {
18 if (object.property.type === 'JSXIdentifier') {
19 reversedIdentifiers.push(object.property.name)
20 }
21 object = object.object
22 }
23
24 if (object.type === 'JSXIdentifier') {
25 reversedIdentifiers.push(object.name)
26 }
27 }
28
29 return reversedIdentifiers.reverse().join('.')
30}
31
32module.exports = {
33 meta: {
34 type: 'problem',
35 docs: {
36 description: 'Enforce text strings are wrapped in <Text> components',
37 },
38 schema: [
39 {
40 type: 'object',
41 properties: {
42 impliedTextComponents: {
43 type: 'array',
44 items: {type: 'string'},
45 },
46 impliedTextProps: {
47 type: 'array',
48 items: {type: 'string'},
49 },
50 suggestedTextWrappers: {
51 type: 'object',
52 additionalProperties: {type: 'string'},
53 },
54 },
55 additionalProperties: false,
56 },
57 ],
58 },
59 create(context) {
60 const options = context.options[0] || {}
61 const impliedTextProps = options.impliedTextProps ?? []
62 const impliedTextComponents = options.impliedTextComponents ?? []
63 const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
64 const textProps = [...impliedTextProps]
65 const textComponents = ['Text', ...impliedTextComponents]
66
67 function isTextComponent(tagName) {
68 return textComponents.includes(tagName) || tagName.endsWith('Text')
69 }
70
71 return {
72 JSXText(node) {
73 if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
74 return
75 }
76 let parent = node.parent
77 while (parent) {
78 if (parent.type === 'JSXElement') {
79 const tagName = getTagName(parent)
80 if (isTextComponent(tagName)) {
81 // We're good.
82 return
83 }
84 if (tagName === 'Trans') {
85 // Exit and rely on the traversal for <Trans> JSXElement (code below).
86 // TODO: Maybe validate that it's present.
87 return
88 }
89 const suggestedWrapper = suggestedTextWrappers[tagName]
90 let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
91 if (tagName !== 'View' && !suggestedWrapper) {
92 message +=
93 ' If <' +
94 tagName +
95 '> is guaranteed to render <Text>, ' +
96 'rename it to <' +
97 tagName +
98 'Text> or add it to impliedTextComponents.'
99 }
100 context.report({
101 node,
102 message,
103 })
104 return
105 }
106
107 if (
108 parent.type === 'JSXAttribute' &&
109 parent.name.type === 'JSXIdentifier' &&
110 parent.parent.type === 'JSXOpeningElement' &&
111 parent.parent.parent.type === 'JSXElement'
112 ) {
113 const tagName = getTagName(parent.parent.parent)
114 const propName = parent.name.name
115 if (
116 textProps.includes(tagName + ' ' + propName) ||
117 propName === 'text' ||
118 propName.endsWith('Text')
119 ) {
120 // We're good.
121 return
122 }
123 const message =
124 'Wrap this string in <Text>.' +
125 ' If `' +
126 propName +
127 '` is guaranteed to be wrapped in <Text>, ' +
128 'rename it to `' +
129 propName +
130 'Text' +
131 '` or add it to impliedTextProps.'
132 context.report({
133 node,
134 message,
135 })
136 return
137 }
138
139 parent = parent.parent
140 continue
141 }
142 },
143 Literal(node) {
144 if (typeof node.value !== 'string' && typeof node.value !== 'number') {
145 return
146 }
147 let parent = node.parent
148 while (parent) {
149 if (parent.type === 'JSXElement') {
150 const tagName = getTagName(parent)
151 if (isTextComponent(tagName)) {
152 // We're good.
153 return
154 }
155 if (tagName === 'Trans') {
156 // Exit and rely on the traversal for <Trans> JSXElement (code below).
157 // TODO: Maybe validate that it's present.
158 return
159 }
160 const suggestedWrapper = suggestedTextWrappers[tagName]
161 let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
162 if (tagName !== 'View' && !suggestedWrapper) {
163 message +=
164 ' If <' +
165 tagName +
166 '> is guaranteed to render <Text>, ' +
167 'rename it to <' +
168 tagName +
169 'Text> or add it to impliedTextComponents.'
170 }
171 context.report({
172 node,
173 message,
174 })
175 return
176 }
177
178 if (parent.type === 'BinaryExpression' && parent.operator === '+') {
179 parent = parent.parent
180 continue
181 }
182
183 if (
184 parent.type === 'JSXExpressionContainer' ||
185 parent.type === 'LogicalExpression'
186 ) {
187 parent = parent.parent
188 continue
189 }
190
191 // Be conservative for other types.
192 return
193 }
194 },
195 TemplateLiteral(node) {
196 let parent = node.parent
197 while (parent) {
198 if (parent.type === 'JSXElement') {
199 const tagName = getTagName(parent)
200 if (isTextComponent(tagName)) {
201 // We're good.
202 return
203 }
204 if (tagName === 'Trans') {
205 // Exit and rely on the traversal for <Trans> JSXElement (code below).
206 // TODO: Maybe validate that it's present.
207 return
208 }
209 const suggestedWrapper = suggestedTextWrappers[tagName]
210 let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
211 if (tagName !== 'View' && !suggestedWrapper) {
212 message +=
213 ' If <' +
214 tagName +
215 '> is guaranteed to render <Text>, ' +
216 'rename it to <' +
217 tagName +
218 'Text> or add it to impliedTextComponents.'
219 }
220 context.report({
221 node,
222 message,
223 })
224 return
225 }
226
227 if (
228 parent.type === 'CallExpression' &&
229 parent.callee.type === 'Identifier' &&
230 parent.callee.name === '_'
231 ) {
232 // This is a user-facing string, keep going up.
233 parent = parent.parent
234 continue
235 }
236
237 if (parent.type === 'BinaryExpression' && parent.operator === '+') {
238 parent = parent.parent
239 continue
240 }
241
242 if (
243 parent.type === 'JSXExpressionContainer' ||
244 parent.type === 'LogicalExpression' ||
245 parent.type === 'TaggedTemplateExpression'
246 ) {
247 parent = parent.parent
248 continue
249 }
250
251 // Be conservative for other types.
252 return
253 }
254 },
255 JSXElement(node) {
256 if (getTagName(node) !== 'Trans') {
257 return
258 }
259 let parent = node.parent
260 while (parent) {
261 if (parent.type === 'JSXElement') {
262 const tagName = getTagName(parent)
263 if (isTextComponent(tagName)) {
264 // We're good.
265 return
266 }
267 if (tagName === 'Trans') {
268 // Exit and rely on the traversal for this JSXElement.
269 // TODO: Should nested <Trans> even be allowed?
270 return
271 }
272 const suggestedWrapper = suggestedTextWrappers[tagName]
273 let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
274 if (tagName !== 'View' && !suggestedWrapper) {
275 message +=
276 ' If <' +
277 tagName +
278 '> is guaranteed to render <Text>, ' +
279 'rename it to <' +
280 tagName +
281 'Text> or add it to impliedTextComponents.'
282 }
283 context.report({
284 node,
285 message,
286 })
287 return
288 }
289
290 if (
291 parent.type === 'JSXAttribute' &&
292 parent.name.type === 'JSXIdentifier' &&
293 parent.parent.type === 'JSXOpeningElement' &&
294 parent.parent.parent.type === 'JSXElement'
295 ) {
296 const tagName = getTagName(parent.parent.parent)
297 const propName = parent.name.name
298 if (
299 textProps.includes(tagName + ' ' + propName) ||
300 propName === 'text' ||
301 propName.endsWith('Text')
302 ) {
303 // We're good.
304 return
305 }
306 const message =
307 'Wrap this <Trans> in <Text>.' +
308 ' If `' +
309 propName +
310 '` is guaranteed to be wrapped in <Text>, ' +
311 'rename it to `' +
312 propName +
313 'Text' +
314 '` or add it to impliedTextProps.'
315 context.report({
316 node,
317 message,
318 })
319 return
320 }
321
322 parent = parent.parent
323 continue
324 }
325 },
326 ReturnStatement(node) {
327 let fnScope = context.sourceCode.getScope(node)
328 while (fnScope && fnScope.type !== 'function') {
329 fnScope = fnScope.upper
330 }
331 if (!fnScope) {
332 return
333 }
334 const fn = fnScope.block
335 if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
336 return
337 }
338 if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
339 return
340 }
341 if (!node.argument || node.argument.type !== 'JSXElement') {
342 return
343 }
344 const openingEl = node.argument.openingElement
345 if (openingEl.name.type !== 'JSXIdentifier') {
346 return
347 }
348 const returnedComponentName = openingEl.name.name
349 if (!isTextComponent(returnedComponentName)) {
350 context.report({
351 node,
352 message:
353 'Components ending with *Text must return <Text> or <SomeText>.',
354 })
355 }
356 },
357 }
358 },
359}