// 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);
}
}
// oxlint-disable-next-line unicorn/no-array-reverse -- local array, built just above
return reversedIdentifiers.reverse().join('.');
}
export default {
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 = new Set(impliedTextProps);
const textComponents = new Set(['Text', ...impliedTextComponents]);
function isTextComponent(tagName) {
return textComponents.has(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)) {
return;
}
if (tagName === 'Trans') {
// let the JSXElement visitor handle Trans.
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.has(tagName + ' ' + propName) || propName === 'text' || propName.endsWith('Text')) {
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)) {
return;
}
if (tagName === 'Trans') {
// let the JSXElement visitor handle Trans.
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;
}
// stop when the parent shape is unknown.
return;
}
},
TemplateLiteral(node) {
let parent = node.parent;
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent);
if (isTextComponent(tagName)) {
return;
}
if (tagName === 'Trans') {
// let the JSXElement visitor handle Trans.
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 === '_'
) {
// continue through the localization call.
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;
}
// stop when the parent shape is unknown.
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)) {
return;
}
if (tagName === 'Trans') {
// let the outer JSXElement visitor handle nested Trans.
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.has(tagName + ' ' + propName) || propName === 'text' || propName.endsWith('Text')) {
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>.',
});
}
},
};
},
};