about summary refs log tree commit diff
path: root/eslint
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-04-04 17:32:50 +0100
committerGitHub <noreply@github.com>2024-04-04 17:32:50 +0100
commit4cc57f4bfdf16fd627ad63bf20ff8193e7d0a12a (patch)
treecdb25d309e141296e8df680bc4a8fda2105912a1 /eslint
parent8e393b16f502ca201393d1fd585c870fee8a4fe9 (diff)
downloadvoidsky-4cc57f4bfdf16fd627ad63bf20ff8193e7d0a12a.tar.zst
Lint against strings without wrapping <Text> (#3398)
* Add a rudimentary rule

* Get the rule passing

* Support special-casing text props

* More tests
Diffstat (limited to 'eslint')
-rw-r--r--eslint/__tests__/avoid-unwrapped-text.test.js423
-rw-r--r--eslint/avoid-unwrapped-text.js111
-rw-r--r--eslint/index.js7
3 files changed, 541 insertions, 0 deletions
diff --git a/eslint/__tests__/avoid-unwrapped-text.test.js b/eslint/__tests__/avoid-unwrapped-text.test.js
new file mode 100644
index 000000000..0fbc01123
--- /dev/null
+++ b/eslint/__tests__/avoid-unwrapped-text.test.js
@@ -0,0 +1,423 @@
+const {RuleTester} = require('eslint')
+const avoidUnwrappedText = require('../avoid-unwrapped-text')
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('@typescript-eslint/parser'),
+  parserOptions: {
+    ecmaFeatures: {
+      jsx: true,
+    },
+    ecmaVersion: 6,
+    sourceType: 'module',
+  },
+})
+
+describe('avoid-unwrapped-text', () => {
+  const tests = {
+    valid: [
+      {
+        code: `
+<Text>
+  foo
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  <Trans>
+    foo
+  </Trans>
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  <>
+    foo
+  </>
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  {foo && <Trans>foo</Trans>}
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  {foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<Trans>
+  <Text>
+    foo
+  </Text>
+</Trans>
+        `,
+      },
+
+      {
+        code: `
+<Trans>
+  {foo && <Text>foo</Text>}
+</Trans>
+              `,
+      },
+
+      {
+        code: `
+<Trans>
+  {foo ? <Text>foo</Text> : <Text>bar</Text>}
+</Trans>
+              `,
+      },
+
+      {
+        code: `
+<CustomText>
+  foo
+</CustomText>
+        `,
+      },
+
+      {
+        code: `
+<CustomText>
+  <Trans>
+    foo
+  </Trans>
+</CustomText>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  {bar}
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<View>
+  {bar}
+</View>
+        `,
+      },
+
+      {
+        code: `
+<Text>
+  foo {bar}
+</Text>
+        `,
+      },
+
+      {
+        code: `
+<View>
+  <Text>
+    foo
+  </Text>
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View>
+  <Text>
+    {bar}
+  </Text>
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View>
+  <Text>
+    foo {bar}
+  </Text>
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View>
+  <CustomText>
+    foo
+  </CustomText>
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View prop={
+  <Text>foo</Text>
+}>
+  <Bar />
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View prop={
+  foo && <Text>foo</Text>
+}>
+  <Bar />
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View prop={
+  foo ? <Text>foo</Text> : <Text>bar</Text>
+}>
+  <Bar />
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View prop={
+  <Trans><Text>foo</Text></Trans>
+}>
+  <Bar />
+</View>
+        `,
+      },
+
+      {
+        code: `
+<View prop={
+  <Text><Trans>foo</Trans></Text>
+}>
+  <Bar />
+</View>
+        `,
+      },
+
+      {
+        code: `
+<Foo propText={
+  <Trans>foo</Trans>
+}>
+  <Bar />
+</Foo>
+        `,
+      },
+
+      {
+        code: `
+<Foo propText={
+  foo && <Trans>foo</Trans>
+}>
+  <Bar />
+</Foo>
+              `,
+      },
+
+      {
+        code: `
+<Foo propText={
+  foo ? <Trans>foo</Trans> : <Trans>bar</Trans>
+}>
+  <Bar />
+</Foo>
+              `,
+      },
+    ],
+
+    invalid: [
+      {
+        code: `
+<View> </View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  foo
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  <>
+    foo
+  </>
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  <Trans>
+    foo
+  </Trans>
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  {foo && <Trans>foo</Trans>}
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  {foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
+</View>
+        `,
+        errors: 2,
+      },
+
+      {
+        code: `
+<Trans>
+  <View>
+    foo
+  </View>
+</Trans>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  foo {bar}
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<View>
+  <View>
+    foo
+  </View>
+</View>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<Text>
+  <View>
+    foo
+  </View>
+</Text>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<Text prop={
+  <View>foo</View>
+}>
+  <Bar />
+</Text>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<Text prop={
+  foo && <View>foo</View>
+}>
+  <Bar />
+</Text>
+        `,
+        errors: 1,
+      },
+
+      {
+        code: `
+<Text prop={
+  foo ? <View>foo</View> : <View>bar</View>
+}>
+  <Bar />
+</Text>
+        `,
+        errors: 2,
+      },
+
+      {
+        code: `
+<Foo prop={
+  <Trans>foo</Trans>
+}>
+  <Bar />
+</Foo>
+        `,
+        errors: 1,
+      },
+    ],
+  }
+
+  // For easier local testing
+  if (!process.env.CI) {
+    let only = []
+    let skipped = []
+    ;[...tests.valid, ...tests.invalid].forEach(t => {
+      if (t.skip) {
+        delete t.skip
+        skipped.push(t)
+      }
+      if (t.only) {
+        delete t.only
+        only.push(t)
+      }
+    })
+    const predicate = t => {
+      if (only.length > 0) {
+        return only.indexOf(t) !== -1
+      }
+      if (skipped.length > 0) {
+        return skipped.indexOf(t) === -1
+      }
+      return true
+    }
+    tests.valid = tests.valid.filter(predicate)
+    tests.invalid = tests.invalid.filter(predicate)
+  }
+  ruleTester.run('avoid-unwrapped-text', avoidUnwrappedText, tests)
+})
diff --git a/eslint/avoid-unwrapped-text.js b/eslint/avoid-unwrapped-text.js
new file mode 100644
index 000000000..c9e72386e
--- /dev/null
+++ b/eslint/avoid-unwrapped-text.js
@@ -0,0 +1,111 @@
+'use strict'
+
+// Partially based on eslint-plugin-react-native.
+// Portions of code by Alex Zhukov, MIT license.
+
+function hasOnlyLineBreak(value) {
+  return /^[\r\n\t\f\v]+$/.test(value.replace(/ /g, ''))
+}
+
+function getTagName(node) {
+  const reversedIdentifiers = []
+  if (
+    node.type === 'JSXElement' &&
+    node.openingElement.type === 'JSXOpeningElement'
+  ) {
+    let object = node.openingElement.name
+    while (object.type === 'JSXMemberExpression') {
+      if (object.property.type === 'JSXIdentifier') {
+        reversedIdentifiers.push(object.property.name)
+      }
+      object = object.object
+    }
+
+    if (object.type === 'JSXIdentifier') {
+      reversedIdentifiers.push(object.name)
+    }
+  }
+
+  return reversedIdentifiers.reverse().join('.')
+}
+
+exports.create = function create(context) {
+  const options = context.options[0] || {}
+  const impliedTextProps = options.impliedTextProps ?? []
+  const impliedTextComponents = options.impliedTextComponents ?? []
+  const textProps = [...impliedTextProps]
+  const textComponents = ['Text', ...impliedTextComponents]
+  return {
+    JSXText(node) {
+      if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
+        return
+      }
+      let parent = node.parent
+      while (parent) {
+        if (parent.type === 'JSXElement') {
+          const tagName = getTagName(parent)
+          if (textComponents.includes(tagName) || tagName.endsWith('Text')) {
+            // We're good.
+            return
+          }
+          if (tagName === 'Trans') {
+            // Skip over it and check above.
+            // TODO: Maybe validate that it's present.
+            parent = parent.parent
+            continue
+          }
+          let message = 'Wrap this string in <Text>.'
+          if (tagName !== 'View') {
+            message +=
+              ' If <' +
+              tagName +
+              '> is guaranteed to render <Text>, ' +
+              'rename it to <' +
+              tagName +
+              'Text> or add it to impliedTextComponents.'
+          }
+          context.report({
+            node,
+            message,
+          })
+          return
+        }
+
+        if (
+          parent.type === 'JSXAttribute' &&
+          parent.name.type === 'JSXIdentifier' &&
+          parent.parent.type === 'JSXOpeningElement' &&
+          parent.parent.parent.type === 'JSXElement'
+        ) {
+          const tagName = getTagName(parent.parent.parent)
+          const propName = parent.name.name
+          if (
+            textProps.includes(tagName + ' ' + propName) ||
+            propName === 'text' ||
+            propName.endsWith('Text')
+          ) {
+            // We're good.
+            return
+          }
+          const message =
+            'Wrap this string in <Text>.' +
+            ' If `' +
+            propName +
+            '` is guaranteed to be wrapped in <Text>, ' +
+            'rename it to `' +
+            propName +
+            'Text' +
+            '` or add it to impliedTextProps.'
+          context.report({
+            node,
+            message,
+          })
+          return
+        }
+
+        parent = parent.parent
+        continue
+      }
+    },
+  }
+}
diff --git a/eslint/index.js b/eslint/index.js
new file mode 100644
index 000000000..daf5bd81d
--- /dev/null
+++ b/eslint/index.js
@@ -0,0 +1,7 @@
+'use strict'
+
+module.exports = {
+  rules: {
+    'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
+  },
+}