bd510d8468
* Upgrade ESLint to v9 with flat config
- Upgrade eslint from v8 to v9.18.0
- Migrate from .eslintrc.js to eslint.config.mjs (flat config)
- Upgrade typescript-eslint to v8.20.0 (unified package)
- Replace eslint-plugin-import with eslint-plugin-import-x for flat config support
- Add globals package for environment globals
- Update eslint-plugin-bsky-internal with proper meta objects for ESLint v9
- Fix deprecated context.getScope() API usage
- Update bskyembed to use flat config
- Remove deprecated --ext flag from lint scripts
- Configure rules to maintain previous behavior while using new ESLint version
* Fix varsIgnorePattern to require character after underscore
Restore the original pattern `^_.+` instead of `^_` so that lingui's
`const { _ } = useLingui()` will still be flagged when unused.
* Update ESLint rule tests for flat config format
- Update RuleTester to use flat config languageOptions instead of
eslintrc parser format
- Remove duplicate test case that ESLint v9 now detects
- Add Jest globals for test files
* update eslint package versions
* lint android a11y
* enable typechecked rules, switch them to warn
* fix yarn lock ci
* Fix CI failure
* Remove unused globals?
* Organize a bit, add quiet to main lint command
* Allow ternary
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
'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('.')
|
|
}
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: 'Enforce text strings are wrapped in <Text> components',
|
|
},
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
impliedTextComponents: {
|
|
type: 'array',
|
|
items: {type: 'string'},
|
|
},
|
|
impliedTextProps: {
|
|
type: 'array',
|
|
items: {type: 'string'},
|
|
},
|
|
suggestedTextWrappers: {
|
|
type: 'object',
|
|
additionalProperties: {type: 'string'},
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
},
|
|
create(context) {
|
|
const options = context.options[0] || {}
|
|
const impliedTextProps = options.impliedTextProps ?? []
|
|
const impliedTextComponents = options.impliedTextComponents ?? []
|
|
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
|
const textProps = [...impliedTextProps]
|
|
const textComponents = ['Text', ...impliedTextComponents]
|
|
|
|
function isTextComponent(tagName) {
|
|
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
|
}
|
|
|
|
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 (isTextComponent(tagName)) {
|
|
// We're good.
|
|
return
|
|
}
|
|
if (tagName === 'Trans') {
|
|
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
|
// TODO: Maybe validate that it's present.
|
|
return
|
|
}
|
|
const suggestedWrapper = suggestedTextWrappers[tagName]
|
|
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
|
if (tagName !== 'View' && !suggestedWrapper) {
|
|
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
|
|
}
|
|
},
|
|
Literal(node) {
|
|
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
|
return
|
|
}
|
|
let parent = node.parent
|
|
while (parent) {
|
|
if (parent.type === 'JSXElement') {
|
|
const tagName = getTagName(parent)
|
|
if (isTextComponent(tagName)) {
|
|
// We're good.
|
|
return
|
|
}
|
|
if (tagName === 'Trans') {
|
|
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
|
// TODO: Maybe validate that it's present.
|
|
return
|
|
}
|
|
const suggestedWrapper = suggestedTextWrappers[tagName]
|
|
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
|
if (tagName !== 'View' && !suggestedWrapper) {
|
|
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 === 'BinaryExpression' && parent.operator === '+') {
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
if (
|
|
parent.type === 'JSXExpressionContainer' ||
|
|
parent.type === 'LogicalExpression'
|
|
) {
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
// Be conservative for other types.
|
|
return
|
|
}
|
|
},
|
|
TemplateLiteral(node) {
|
|
let parent = node.parent
|
|
while (parent) {
|
|
if (parent.type === 'JSXElement') {
|
|
const tagName = getTagName(parent)
|
|
if (isTextComponent(tagName)) {
|
|
// We're good.
|
|
return
|
|
}
|
|
if (tagName === 'Trans') {
|
|
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
|
// TODO: Maybe validate that it's present.
|
|
return
|
|
}
|
|
const suggestedWrapper = suggestedTextWrappers[tagName]
|
|
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
|
if (tagName !== 'View' && !suggestedWrapper) {
|
|
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 === 'CallExpression' &&
|
|
parent.callee.type === 'Identifier' &&
|
|
parent.callee.name === '_'
|
|
) {
|
|
// This is a user-facing string, keep going up.
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
if (
|
|
parent.type === 'JSXExpressionContainer' ||
|
|
parent.type === 'LogicalExpression' ||
|
|
parent.type === 'TaggedTemplateExpression'
|
|
) {
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
// Be conservative for other types.
|
|
return
|
|
}
|
|
},
|
|
JSXElement(node) {
|
|
if (getTagName(node) !== 'Trans') {
|
|
return
|
|
}
|
|
let parent = node.parent
|
|
while (parent) {
|
|
if (parent.type === 'JSXElement') {
|
|
const tagName = getTagName(parent)
|
|
if (isTextComponent(tagName)) {
|
|
// We're good.
|
|
return
|
|
}
|
|
if (tagName === 'Trans') {
|
|
// Exit and rely on the traversal for this JSXElement.
|
|
// TODO: Should nested <Trans> even be allowed?
|
|
return
|
|
}
|
|
const suggestedWrapper = suggestedTextWrappers[tagName]
|
|
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
|
if (tagName !== 'View' && !suggestedWrapper) {
|
|
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 <Trans> 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
|
|
}
|
|
},
|
|
ReturnStatement(node) {
|
|
let fnScope = context.sourceCode.getScope(node)
|
|
while (fnScope && fnScope.type !== 'function') {
|
|
fnScope = fnScope.upper
|
|
}
|
|
if (!fnScope) {
|
|
return
|
|
}
|
|
const fn = fnScope.block
|
|
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
|
return
|
|
}
|
|
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
|
return
|
|
}
|
|
if (!node.argument || node.argument.type !== 'JSXElement') {
|
|
return
|
|
}
|
|
const openingEl = node.argument.openingElement
|
|
if (openingEl.name.type !== 'JSXIdentifier') {
|
|
return
|
|
}
|
|
const returnedComponentName = openingEl.name.name
|
|
if (!isTextComponent(returnedComponentName)) {
|
|
context.report({
|
|
node,
|
|
message:
|
|
'Components ending with *Text must return <Text> or <SomeText>.',
|
|
})
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|