Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Lint against strings without wrapping <Text> (#3398)

* Add a rudimentary rule

* Get the rule passing

* Support special-casing text props

* More tests

authored by

dan and committed by
GitHub
4cc57f4b 8e393b16

+579
+33
.eslintrc.js
··· 1 + const bskyEslint = require('./eslint') 2 + 1 3 module.exports = { 2 4 root: true, 3 5 extends: [ ··· 13 15 'react', 14 16 'lingui', 15 17 'simple-import-sort', 18 + 'bsky-internal', 16 19 ], 17 20 rules: { 18 21 // Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release. 19 22 'prettier/prettier': 0, 20 23 'react/no-unescaped-entities': 0, 21 24 'react-native/no-inline-styles': 0, 25 + 'bsky-internal/avoid-unwrapped-text': [ 26 + 'error', 27 + { 28 + impliedTextComponents: [ 29 + 'Button', // TODO: Not always safe. 30 + 'ButtonText', 31 + 'DateField.Label', 32 + 'Description', 33 + 'H1', 34 + 'H2', 35 + 'H3', 36 + 'H4', 37 + 'H5', 38 + 'H6', 39 + 'InlineLink', 40 + 'Label', 41 + 'P', 42 + 'Prompt.Title', 43 + 'Prompt.Description', 44 + 'Prompt.Cancel', // TODO: Not always safe. 45 + 'Prompt.Action', // TODO: Not always safe. 46 + 'TextField.Label', 47 + 'TextField.Suffix', 48 + 'Title', 49 + 'Toggle.Label', 50 + 'ToggleButton.Button', // TODO: Not always safe. 51 + ], 52 + impliedTextProps: ['FormContainer title'], 53 + }, 54 + ], 22 55 'simple-import-sort/imports': [ 23 56 'warn', 24 57 {
+423
eslint/__tests__/avoid-unwrapped-text.test.js
··· 1 + const {RuleTester} = require('eslint') 2 + const avoidUnwrappedText = require('../avoid-unwrapped-text') 3 + 4 + const ruleTester = new RuleTester({ 5 + parser: require.resolve('@typescript-eslint/parser'), 6 + parserOptions: { 7 + ecmaFeatures: { 8 + jsx: true, 9 + }, 10 + ecmaVersion: 6, 11 + sourceType: 'module', 12 + }, 13 + }) 14 + 15 + describe('avoid-unwrapped-text', () => { 16 + const tests = { 17 + valid: [ 18 + { 19 + code: ` 20 + <Text> 21 + foo 22 + </Text> 23 + `, 24 + }, 25 + 26 + { 27 + code: ` 28 + <Text> 29 + <Trans> 30 + foo 31 + </Trans> 32 + </Text> 33 + `, 34 + }, 35 + 36 + { 37 + code: ` 38 + <Text> 39 + <> 40 + foo 41 + </> 42 + </Text> 43 + `, 44 + }, 45 + 46 + { 47 + code: ` 48 + <Text> 49 + {foo && <Trans>foo</Trans>} 50 + </Text> 51 + `, 52 + }, 53 + 54 + { 55 + code: ` 56 + <Text> 57 + {foo ? <Trans>foo</Trans> : <Trans>bar</Trans>} 58 + </Text> 59 + `, 60 + }, 61 + 62 + { 63 + code: ` 64 + <Trans> 65 + <Text> 66 + foo 67 + </Text> 68 + </Trans> 69 + `, 70 + }, 71 + 72 + { 73 + code: ` 74 + <Trans> 75 + {foo && <Text>foo</Text>} 76 + </Trans> 77 + `, 78 + }, 79 + 80 + { 81 + code: ` 82 + <Trans> 83 + {foo ? <Text>foo</Text> : <Text>bar</Text>} 84 + </Trans> 85 + `, 86 + }, 87 + 88 + { 89 + code: ` 90 + <CustomText> 91 + foo 92 + </CustomText> 93 + `, 94 + }, 95 + 96 + { 97 + code: ` 98 + <CustomText> 99 + <Trans> 100 + foo 101 + </Trans> 102 + </CustomText> 103 + `, 104 + }, 105 + 106 + { 107 + code: ` 108 + <Text> 109 + {bar} 110 + </Text> 111 + `, 112 + }, 113 + 114 + { 115 + code: ` 116 + <View> 117 + {bar} 118 + </View> 119 + `, 120 + }, 121 + 122 + { 123 + code: ` 124 + <Text> 125 + foo {bar} 126 + </Text> 127 + `, 128 + }, 129 + 130 + { 131 + code: ` 132 + <View> 133 + <Text> 134 + foo 135 + </Text> 136 + </View> 137 + `, 138 + }, 139 + 140 + { 141 + code: ` 142 + <View> 143 + <Text> 144 + {bar} 145 + </Text> 146 + </View> 147 + `, 148 + }, 149 + 150 + { 151 + code: ` 152 + <View> 153 + <Text> 154 + foo {bar} 155 + </Text> 156 + </View> 157 + `, 158 + }, 159 + 160 + { 161 + code: ` 162 + <View> 163 + <CustomText> 164 + foo 165 + </CustomText> 166 + </View> 167 + `, 168 + }, 169 + 170 + { 171 + code: ` 172 + <View prop={ 173 + <Text>foo</Text> 174 + }> 175 + <Bar /> 176 + </View> 177 + `, 178 + }, 179 + 180 + { 181 + code: ` 182 + <View prop={ 183 + foo && <Text>foo</Text> 184 + }> 185 + <Bar /> 186 + </View> 187 + `, 188 + }, 189 + 190 + { 191 + code: ` 192 + <View prop={ 193 + foo ? <Text>foo</Text> : <Text>bar</Text> 194 + }> 195 + <Bar /> 196 + </View> 197 + `, 198 + }, 199 + 200 + { 201 + code: ` 202 + <View prop={ 203 + <Trans><Text>foo</Text></Trans> 204 + }> 205 + <Bar /> 206 + </View> 207 + `, 208 + }, 209 + 210 + { 211 + code: ` 212 + <View prop={ 213 + <Text><Trans>foo</Trans></Text> 214 + }> 215 + <Bar /> 216 + </View> 217 + `, 218 + }, 219 + 220 + { 221 + code: ` 222 + <Foo propText={ 223 + <Trans>foo</Trans> 224 + }> 225 + <Bar /> 226 + </Foo> 227 + `, 228 + }, 229 + 230 + { 231 + code: ` 232 + <Foo propText={ 233 + foo && <Trans>foo</Trans> 234 + }> 235 + <Bar /> 236 + </Foo> 237 + `, 238 + }, 239 + 240 + { 241 + code: ` 242 + <Foo propText={ 243 + foo ? <Trans>foo</Trans> : <Trans>bar</Trans> 244 + }> 245 + <Bar /> 246 + </Foo> 247 + `, 248 + }, 249 + ], 250 + 251 + invalid: [ 252 + { 253 + code: ` 254 + <View> </View> 255 + `, 256 + errors: 1, 257 + }, 258 + 259 + { 260 + code: ` 261 + <View> 262 + foo 263 + </View> 264 + `, 265 + errors: 1, 266 + }, 267 + 268 + { 269 + code: ` 270 + <View> 271 + <> 272 + foo 273 + </> 274 + </View> 275 + `, 276 + errors: 1, 277 + }, 278 + 279 + { 280 + code: ` 281 + <View> 282 + <Trans> 283 + foo 284 + </Trans> 285 + </View> 286 + `, 287 + errors: 1, 288 + }, 289 + 290 + { 291 + code: ` 292 + <View> 293 + {foo && <Trans>foo</Trans>} 294 + </View> 295 + `, 296 + errors: 1, 297 + }, 298 + 299 + { 300 + code: ` 301 + <View> 302 + {foo ? <Trans>foo</Trans> : <Trans>bar</Trans>} 303 + </View> 304 + `, 305 + errors: 2, 306 + }, 307 + 308 + { 309 + code: ` 310 + <Trans> 311 + <View> 312 + foo 313 + </View> 314 + </Trans> 315 + `, 316 + errors: 1, 317 + }, 318 + 319 + { 320 + code: ` 321 + <View> 322 + foo {bar} 323 + </View> 324 + `, 325 + errors: 1, 326 + }, 327 + 328 + { 329 + code: ` 330 + <View> 331 + <View> 332 + foo 333 + </View> 334 + </View> 335 + `, 336 + errors: 1, 337 + }, 338 + 339 + { 340 + code: ` 341 + <Text> 342 + <View> 343 + foo 344 + </View> 345 + </Text> 346 + `, 347 + errors: 1, 348 + }, 349 + 350 + { 351 + code: ` 352 + <Text prop={ 353 + <View>foo</View> 354 + }> 355 + <Bar /> 356 + </Text> 357 + `, 358 + errors: 1, 359 + }, 360 + 361 + { 362 + code: ` 363 + <Text prop={ 364 + foo && <View>foo</View> 365 + }> 366 + <Bar /> 367 + </Text> 368 + `, 369 + errors: 1, 370 + }, 371 + 372 + { 373 + code: ` 374 + <Text prop={ 375 + foo ? <View>foo</View> : <View>bar</View> 376 + }> 377 + <Bar /> 378 + </Text> 379 + `, 380 + errors: 2, 381 + }, 382 + 383 + { 384 + code: ` 385 + <Foo prop={ 386 + <Trans>foo</Trans> 387 + }> 388 + <Bar /> 389 + </Foo> 390 + `, 391 + errors: 1, 392 + }, 393 + ], 394 + } 395 + 396 + // For easier local testing 397 + if (!process.env.CI) { 398 + let only = [] 399 + let skipped = [] 400 + ;[...tests.valid, ...tests.invalid].forEach(t => { 401 + if (t.skip) { 402 + delete t.skip 403 + skipped.push(t) 404 + } 405 + if (t.only) { 406 + delete t.only 407 + only.push(t) 408 + } 409 + }) 410 + const predicate = t => { 411 + if (only.length > 0) { 412 + return only.indexOf(t) !== -1 413 + } 414 + if (skipped.length > 0) { 415 + return skipped.indexOf(t) === -1 416 + } 417 + return true 418 + } 419 + tests.valid = tests.valid.filter(predicate) 420 + tests.invalid = tests.invalid.filter(predicate) 421 + } 422 + ruleTester.run('avoid-unwrapped-text', avoidUnwrappedText, tests) 423 + })
+111
eslint/avoid-unwrapped-text.js
··· 1 + 'use strict' 2 + 3 + // Partially based on eslint-plugin-react-native. 4 + // Portions of code by Alex Zhukov, MIT license. 5 + 6 + function hasOnlyLineBreak(value) { 7 + return /^[\r\n\t\f\v]+$/.test(value.replace(/ /g, '')) 8 + } 9 + 10 + function 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 + exports.create = function create(context) { 33 + const options = context.options[0] || {} 34 + const impliedTextProps = options.impliedTextProps ?? [] 35 + const impliedTextComponents = options.impliedTextComponents ?? [] 36 + const textProps = [...impliedTextProps] 37 + const textComponents = ['Text', ...impliedTextComponents] 38 + return { 39 + JSXText(node) { 40 + if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) { 41 + return 42 + } 43 + let parent = node.parent 44 + while (parent) { 45 + if (parent.type === 'JSXElement') { 46 + const tagName = getTagName(parent) 47 + if (textComponents.includes(tagName) || tagName.endsWith('Text')) { 48 + // We're good. 49 + return 50 + } 51 + if (tagName === 'Trans') { 52 + // Skip over it and check above. 53 + // TODO: Maybe validate that it's present. 54 + parent = parent.parent 55 + continue 56 + } 57 + let message = 'Wrap this string in <Text>.' 58 + if (tagName !== 'View') { 59 + message += 60 + ' If <' + 61 + tagName + 62 + '> is guaranteed to render <Text>, ' + 63 + 'rename it to <' + 64 + tagName + 65 + 'Text> or add it to impliedTextComponents.' 66 + } 67 + context.report({ 68 + node, 69 + message, 70 + }) 71 + return 72 + } 73 + 74 + if ( 75 + parent.type === 'JSXAttribute' && 76 + parent.name.type === 'JSXIdentifier' && 77 + parent.parent.type === 'JSXOpeningElement' && 78 + parent.parent.parent.type === 'JSXElement' 79 + ) { 80 + const tagName = getTagName(parent.parent.parent) 81 + const propName = parent.name.name 82 + if ( 83 + textProps.includes(tagName + ' ' + propName) || 84 + propName === 'text' || 85 + propName.endsWith('Text') 86 + ) { 87 + // We're good. 88 + return 89 + } 90 + const message = 91 + 'Wrap this string in <Text>.' + 92 + ' If `' + 93 + propName + 94 + '` is guaranteed to be wrapped in <Text>, ' + 95 + 'rename it to `' + 96 + propName + 97 + 'Text' + 98 + '` or add it to impliedTextProps.' 99 + context.report({ 100 + node, 101 + message, 102 + }) 103 + return 104 + } 105 + 106 + parent = parent.parent 107 + continue 108 + } 109 + }, 110 + } 111 + }
+7
eslint/index.js
··· 1 + 'use strict' 2 + 3 + module.exports = { 4 + rules: { 5 + 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 6 + }, 7 + }
+1
package.json
··· 236 236 "babel-preset-expo": "^10.0.0", 237 237 "detox": "^20.14.8", 238 238 "eslint": "^8.19.0", 239 + "eslint-plugin-bsky-internal": "link:./eslint", 239 240 "eslint-plugin-detox": "^1.0.0", 240 241 "eslint-plugin-ft-flow": "^2.0.3", 241 242 "eslint-plugin-lingui": "^0.2.0",
+4
yarn.lock
··· 11404 11404 dependencies: 11405 11405 debug "^3.2.7" 11406 11406 11407 + "eslint-plugin-bsky-internal@link:./eslint": 11408 + version "0.0.0" 11409 + uid "" 11410 + 11407 11411 eslint-plugin-detox@^1.0.0: 11408 11412 version "1.0.0" 11409 11413 resolved "https://registry.yarnpkg.com/eslint-plugin-detox/-/eslint-plugin-detox-1.0.0.tgz#2d9c0130e8ebc4ced56efb6eeaf0d0f5c163398d"