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