Bluesky app fork with some witchin' additions 馃挮
at readme-update 359 lines 11 kB view raw
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}