Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 360 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 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}