the-forest/client/node_modules/eslint-plugin-react/lib/rules/jsx-no-literals.js
2024-09-17 20:35:18 -04:00

556 lines
17 KiB
JavaScript

/**
* @fileoverview Prevent using string literals in React component definition
* @author Caleb Morris
* @author David Buchan-Swanson
*/
'use strict';
const iterFrom = require('es-iterator-helpers/Iterator.from');
const map = require('es-iterator-helpers/Iterator.prototype.map');
const some = require('es-iterator-helpers/Iterator.prototype.some');
const flatMap = require('es-iterator-helpers/Iterator.prototype.flatMap');
const fromEntries = require('object.fromentries');
const entries = require('object.entries');
const docsUrl = require('../util/docsUrl');
const report = require('../util/report');
const getText = require('../util/eslint').getText;
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
/**
* @param {unknown} value
* @returns {string | unknown}
*/
function trimIfString(value) {
return typeof value === 'string' ? value.trim() : value;
}
const reOverridableElement = /^[A-Z][\w.]*$/;
const reIsWhiteSpace = /^[\s]+$/;
const jsxElementTypes = new Set(['JSXElement', 'JSXFragment']);
const standardJSXNodeParentTypes = new Set(['JSXAttribute', 'JSXElement', 'JSXExpressionContainer', 'JSXFragment']);
const messages = {
invalidPropValue: 'Invalid prop value: "{{text}}"',
invalidPropValueInElement: 'Invalid prop value: "{{text}}" in {{element}}',
noStringsInAttributes: 'Strings not allowed in attributes: "{{text}}"',
noStringsInAttributesInElement: 'Strings not allowed in attributes: "{{text}}" in {{element}}',
noStringsInJSX: 'Strings not allowed in JSX files: "{{text}}"',
noStringsInJSXInElement: 'Strings not allowed in JSX files: "{{text}}" in {{element}}',
literalNotInJSXExpression: 'Missing JSX expression container around literal string: "{{text}}"',
literalNotInJSXExpressionInElement: 'Missing JSX expression container around literal string: "{{text}}" in {{element}}',
};
/** @type {Exclude<import('eslint').Rule.RuleModule['meta']['schema'], unknown[]>['properties']} */
const commonPropertiesSchema = {
noStrings: {
type: 'boolean',
},
allowedStrings: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
ignoreProps: {
type: 'boolean',
},
noAttributeStrings: {
type: 'boolean',
},
};
/**
* @typedef RawElementConfigProperties
* @property {boolean} [noStrings]
* @property {string[]} [allowedStrings]
* @property {boolean} [ignoreProps]
* @property {boolean} [noAttributeStrings]
*
* @typedef RawOverrideConfigProperties
* @property {boolean} [allowElement]
* @property {boolean} [applyToNestedElements=true]
*
* @typedef {RawElementConfigProperties} RawElementConfig
* @typedef {RawElementConfigProperties & RawElementConfigProperties} RawOverrideConfig
*
* @typedef RawElementOverrides
* @property {Record<string, RawOverrideConfig>} [elementOverrides]
*
* @typedef {RawElementConfig & RawElementOverrides} RawConfig
*
* ----------------------------------------------------------------------
*
* @typedef ElementConfigType
* @property {'element'} type
*
* @typedef ElementConfigProperties
* @property {boolean} noStrings
* @property {Set<string>} allowedStrings
* @property {boolean} ignoreProps
* @property {boolean} noAttributeStrings
*
* @typedef OverrideConfigProperties
* @property {'override'} type
* @property {string} name
* @property {boolean} allowElement
* @property {boolean} applyToNestedElements
*
* @typedef {ElementConfigType & ElementConfigProperties} ElementConfig
* @typedef {OverrideConfigProperties & ElementConfigProperties} OverrideConfig
*
* @typedef ElementOverrides
* @property {Record<string, OverrideConfig>} elementOverrides
*
* @typedef {ElementConfig & ElementOverrides} Config
* @typedef {Config | OverrideConfig} ResolvedConfig
*/
/**
* Normalizes the element portion of the config
* @param {RawConfig} config
* @returns {ElementConfig}
*/
function normalizeElementConfig(config) {
return {
type: 'element',
noStrings: !!config.noStrings,
allowedStrings: config.allowedStrings
? new Set(map(iterFrom(config.allowedStrings), trimIfString))
: new Set(),
ignoreProps: !!config.ignoreProps,
noAttributeStrings: !!config.noAttributeStrings,
};
}
/**
* Normalizes the config and applies default values to all config options
* @param {RawConfig} config
* @returns {Config}
*/
function normalizeConfig(config) {
/** @type {Config} */
const normalizedConfig = Object.assign(normalizeElementConfig(config), {
elementOverrides: {},
});
if (config.elementOverrides) {
normalizedConfig.elementOverrides = fromEntries(
flatMap(
iterFrom(entries(config.elementOverrides)),
(entry) => {
const elementName = entry[0];
const rawElementConfig = entry[1];
if (!reOverridableElement.test(elementName)) {
return [];
}
return [[
elementName,
Object.assign(normalizeElementConfig(rawElementConfig), {
type: 'override',
name: elementName,
allowElement: !!rawElementConfig.allowElement,
applyToNestedElements: typeof rawElementConfig.applyToNestedElements === 'undefined' || !!rawElementConfig.applyToNestedElements,
}),
]];
}
)
);
}
return normalizedConfig;
}
const elementOverrides = {
type: 'object',
patternProperties: {
[reOverridableElement.source]: {
type: 'object',
properties: Object.assign(
{ applyToNestedElements: { type: 'boolean' } },
commonPropertiesSchema
),
},
},
};
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
docs: {
description: 'Disallow usage of string literals in JSX',
category: 'Stylistic Issues',
recommended: false,
url: docsUrl('jsx-no-literals'),
},
messages,
schema: [{
type: 'object',
properties: Object.assign(
{ elementOverrides },
commonPropertiesSchema
),
additionalProperties: false,
}],
},
create(context) {
/** @type {RawConfig} */
const rawConfig = (context.options.length && context.options[0]) || {};
const config = normalizeConfig(rawConfig);
const hasElementOverrides = Object.keys(config.elementOverrides).length > 0;
/** @type {Map<string, string>} */
const renamedImportMap = new Map();
/**
* Determines if the given expression is a require statement. Supports
* nested MemberExpresions. ie `require('foo').nested.property`
* @param {ASTNode} node
* @returns {boolean}
*/
function isRequireStatement(node) {
if (node.type === 'CallExpression') {
if (node.callee.type === 'Identifier') {
return node.callee.name === 'require';
}
}
if (node.type === 'MemberExpression') {
return isRequireStatement(node.object);
}
return false;
}
/** @typedef {{ name: string, compoundName?: string }} ElementNameFragment */
/**
* Gets the name of the given JSX element. Supports nested
* JSXMemeberExpressions. ie `<Namesapce.Component.SubComponent />`
* @param {ASTNode} node
* @returns {ElementNameFragment | undefined}
*/
function getJSXElementName(node) {
if (node.openingElement.name.type === 'JSXIdentifier') {
const name = node.openingElement.name.name;
return {
name: renamedImportMap.get(name) || name,
compoundName: undefined,
};
}
/** @type {string[]} */
const nameFragments = [];
if (node.openingElement.name.type === 'JSXMemberExpression') {
/** @type {ASTNode} */
let current = node.openingElement.name;
while (current.type === 'JSXMemberExpression') {
if (current.property.type === 'JSXIdentifier') {
nameFragments.unshift(current.property.name);
}
current = current.object;
}
if (current.type === 'JSXIdentifier') {
nameFragments.unshift(current.name);
const rootFragment = nameFragments[0];
if (rootFragment) {
const rootFragmentRenamed = renamedImportMap.get(rootFragment);
if (rootFragmentRenamed) {
nameFragments[0] = rootFragmentRenamed;
}
}
const nameFragment = nameFragments[nameFragments.length - 1];
if (nameFragment) {
return {
name: nameFragment,
compoundName: nameFragments.join('.'),
};
}
}
}
}
/**
* Gets all JSXElement ancestor nodes for the given node
* @param {ASTNode} node
* @returns {ASTNode[]}
*/
function getJSXElementAncestors(node) {
/** @type {ASTNode[]} */
const ancestors = [];
let current = node;
while (current) {
if (current.type === 'JSXElement') {
ancestors.push(current);
}
current = current.parent;
}
return ancestors;
}
/**
* @param {ASTNode} node
* @returns {ASTNode}
*/
function getParentIgnoringBinaryExpressions(node) {
let current = node;
while (current.parent.type === 'BinaryExpression') {
current = current.parent;
}
return current.parent;
}
/**
* @param {ASTNode} node
* @returns {{ parent: ASTNode, grandParent: ASTNode }}
*/
function getParentAndGrandParent(node) {
const parent = getParentIgnoringBinaryExpressions(node);
return {
parent,
grandParent: parent.parent,
};
}
/**
* @param {ASTNode} node
* @returns {boolean}
*/
function hasJSXElementParentOrGrandParent(node) {
const ancestors = getParentAndGrandParent(node);
return some(iterFrom([ancestors.parent, ancestors.grandParent]), (parent) => jsxElementTypes.has(parent.type));
}
/**
* Determines whether a given node's value and its immediate parent are
* viable text nodes that can/should be reported on
* @param {ASTNode} node
* @param {ResolvedConfig} resolvedConfig
* @returns {boolean}
*/
function isViableTextNode(node, resolvedConfig) {
const textValues = iterFrom([trimIfString(node.raw), trimIfString(node.value)]);
if (some(textValues, (value) => resolvedConfig.allowedStrings.has(value))) {
return false;
}
const parent = getParentIgnoringBinaryExpressions(node);
let isStandardJSXNode = false;
if (typeof node.value === 'string' && !reIsWhiteSpace.test(node.value) && standardJSXNodeParentTypes.has(parent.type)) {
if (resolvedConfig.noAttributeStrings) {
isStandardJSXNode = parent.type === 'JSXAttribute' || parent.type === 'JSXElement';
} else {
isStandardJSXNode = parent.type !== 'JSXAttribute';
}
}
if (resolvedConfig.noStrings) {
return isStandardJSXNode;
}
return isStandardJSXNode && parent.type !== 'JSXExpressionContainer';
}
/**
* Gets an override config for a given node. For any given node, we also
* need to traverse the ancestor tree to determine if an ancestor's config
* will also apply to the current node.
* @param {ASTNode} node
* @returns {OverrideConfig | undefined}
*/
function getOverrideConfig(node) {
if (!hasElementOverrides) {
return;
}
const allAncestorElements = getJSXElementAncestors(node);
if (!allAncestorElements.length) {
return;
}
for (const ancestorElement of allAncestorElements) {
const isClosestJSXAncestor = ancestorElement === allAncestorElements[0];
const ancestor = getJSXElementName(ancestorElement);
if (ancestor) {
if (ancestor.name) {
const ancestorElements = config.elementOverrides[ancestor.name];
const ancestorConfig = ancestor.compoundName
? config.elementOverrides[ancestor.compoundName] || ancestorElements
: ancestorElements;
if (ancestorConfig) {
if (isClosestJSXAncestor || ancestorConfig.applyToNestedElements) {
return ancestorConfig;
}
}
}
}
}
}
/**
* @param {ResolvedConfig} resolvedConfig
* @returns {boolean}
*/
function shouldAllowElement(resolvedConfig) {
return resolvedConfig.type === 'override' && 'allowElement' in resolvedConfig && !!resolvedConfig.allowElement;
}
/**
* @param {boolean} ancestorIsJSXElement
* @param {ResolvedConfig} resolvedConfig
* @returns {string}
*/
function defaultMessageId(ancestorIsJSXElement, resolvedConfig) {
if (resolvedConfig.noAttributeStrings && !ancestorIsJSXElement) {
return resolvedConfig.type === 'override' ? 'noStringsInAttributesInElement' : 'noStringsInAttributes';
}
if (resolvedConfig.noStrings) {
return resolvedConfig.type === 'override' ? 'noStringsInJSXInElement' : 'noStringsInJSX';
}
return resolvedConfig.type === 'override' ? 'literalNotInJSXExpressionInElement' : 'literalNotInJSXExpression';
}
/**
* @param {ASTNode} node
* @param {string} messageId
* @param {ResolvedConfig} resolvedConfig
*/
function reportLiteralNode(node, messageId, resolvedConfig) {
report(context, messages[messageId], messageId, {
node,
data: {
text: getText(context, node).trim(),
element: resolvedConfig.type === 'override' && 'name' in resolvedConfig ? resolvedConfig.name : undefined,
},
});
}
// --------------------------------------------------------------------------
// Public
// --------------------------------------------------------------------------
return Object.assign(hasElementOverrides ? {
// Get renamed import local names mapped to their imported name
ImportDeclaration(node) {
node.specifiers
.filter((s) => s.type === 'ImportSpecifier')
.forEach((specifier) => {
renamedImportMap.set(
(specifier.local || specifier.imported).name,
specifier.imported.name
);
});
},
// Get renamed destructured local names mapped to their imported name
VariableDeclaration(node) {
node.declarations
.filter((d) => (
d.type === 'VariableDeclarator'
&& isRequireStatement(d.init)
&& d.id.type === 'ObjectPattern'
))
.forEach((declaration) => {
declaration.id.properties
.filter((property) => (
property.type === 'Property'
&& property.key.type === 'Identifier'
&& property.value.type === 'Identifier'
))
.forEach((property) => {
renamedImportMap.set(property.value.name, property.key.name);
});
});
},
} : false, {
Literal(node) {
const resolvedConfig = getOverrideConfig(node) || config;
const hasJSXParentOrGrandParent = hasJSXElementParentOrGrandParent(node);
if (hasJSXParentOrGrandParent && shouldAllowElement(resolvedConfig)) {
return;
}
if (isViableTextNode(node, resolvedConfig)) {
if (hasJSXParentOrGrandParent || !config.ignoreProps) {
reportLiteralNode(node, defaultMessageId(hasJSXParentOrGrandParent, resolvedConfig), resolvedConfig);
}
}
},
JSXAttribute(node) {
const isLiteralString = node.value && node.value.type === 'Literal'
&& typeof node.value.value === 'string';
const isStringLiteral = node.value && node.value.type === 'StringLiteral';
if (isLiteralString || isStringLiteral) {
const resolvedConfig = getOverrideConfig(node) || config;
if (
resolvedConfig.noStrings
&& !resolvedConfig.ignoreProps
&& !resolvedConfig.allowedStrings.has(node.value.value)
) {
const messageId = resolvedConfig.type === 'override' ? 'invalidPropValueInElement' : 'invalidPropValue';
reportLiteralNode(node, messageId, resolvedConfig);
}
}
},
JSXText(node) {
const resolvedConfig = getOverrideConfig(node) || config;
if (shouldAllowElement(resolvedConfig)) {
return;
}
if (isViableTextNode(node, resolvedConfig)) {
const hasJSXParendOrGrantParent = hasJSXElementParentOrGrandParent(node);
reportLiteralNode(node, defaultMessageId(hasJSXParendOrGrantParent, resolvedConfig), resolvedConfig);
}
},
TemplateLiteral(node) {
const ancestors = getParentAndGrandParent(node);
const isParentJSXExpressionCont = ancestors.parent.type === 'JSXExpressionContainer';
const isParentJSXElement = ancestors.grandParent.type === 'JSXElement';
if (isParentJSXExpressionCont) {
const resolvedConfig = getOverrideConfig(node) || config;
if (
resolvedConfig.noStrings
&& (isParentJSXElement || !resolvedConfig.ignoreProps)
) {
reportLiteralNode(node, defaultMessageId(isParentJSXElement, resolvedConfig), resolvedConfig);
}
}
},
});
},
};