/** * @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['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} [elementOverrides] * * @typedef {RawElementConfig & RawElementOverrides} RawConfig * * ---------------------------------------------------------------------- * * @typedef ElementConfigType * @property {'element'} type * * @typedef ElementConfigProperties * @property {boolean} noStrings * @property {Set} 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} 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} */ 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 `` * @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); } } }, }); }, };