import * as BabelCore from '@babel/core'; import * as BabelTypes from '@babel/types'; import { COMMENT_HINTS_KEYWORDS, getCommentHintForPath, CommentHint, } from '../comments'; import { Config } from '../config'; import { ExtractedKey } from '../keys'; import { ExtractionError, getFirstOrNull, findJSXAttributeByName, findKeyInObjectExpression, evaluateIfConfident, iterateObjectExpression, referencesImport, parseI18NextOptionsFromCommentHints, resolveIdentifier, } from './commons'; /** * Check whether a given JSXElement is a Trans component. * @param path: node path to check * @returns true if the given element is indeed a `Trans` component. */ function isTransComponent( path: BabelCore.NodePath, ): boolean { const openingElement = path.get('openingElement'); return referencesImport( openingElement.get('name'), 'react-i18next', 'Trans', ); } /** * Given a Trans component, extract its options. * @param path The node path of the JSX Element of the trans component * @param commentHints Parsed comment hints. * @returns The parsed i18next options */ function parseTransComponentOptions( path: BabelCore.NodePath, commentHints: CommentHint[], ): ExtractedKey['parsedOptions'] { const res: ExtractedKey['parsedOptions'] = { contexts: false, hasCount: false, ns: null, defaultValue: null, }; const countAttr = findJSXAttributeByName(path, 'count'); res.hasCount = countAttr !== null; const tOptionsAttr = findJSXAttributeByName(path, 'tOptions'); if (tOptionsAttr) { const value = tOptionsAttr.get('value'); if (value.isJSXExpressionContainer()) { const expression = value.get('expression'); if (expression.isObjectExpression()) { res.contexts = findKeyInObjectExpression(expression, 'context') !== null; } } } const nsAttr = findJSXAttributeByName(path, 'ns'); if (nsAttr) { let value: BabelCore.NodePath = nsAttr.get( 'value', ); if (value.isJSXExpressionContainer()) value = value.get('expression'); res.ns = getFirstOrNull(evaluateIfConfident(value)); } const defaultsAttr = findJSXAttributeByName(path, 'defaults'); if (defaultsAttr) { let value: BabelCore.NodePath = defaultsAttr.get( 'value', ); if (value.isJSXExpressionContainer()) value = value.get('expression'); res.defaultValue = evaluateIfConfident(value); } return { ...res, ...parseI18NextOptionsFromCommentHints(path, commentHints), }; } /** * Given the node path of a Trans component, try to extract its key from its * attributes. * @param path node path of the Trans component. * @returns the component key if it was found. * @throws ExtractionError if the i18nKey attribute was present but not * evaluable. */ function parseTransComponentKeyFromAttributes( path: BabelCore.NodePath, ): string | null { const error = new ExtractionError( `Couldn't evaluate i18next key in Trans component. You should either ` + `make the i18nKey attribute evaluable or skip the line using a skip ` + `comment (/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + `${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`, path, ); const keyAttribute = findJSXAttributeByName(path, 'i18nKey'); if (!keyAttribute) return null; const keyAttributeValue = keyAttribute.get('value'); const keyEvaluation = evaluateIfConfident( keyAttributeValue.isJSXExpressionContainer() ? keyAttributeValue.get('expression') : keyAttributeValue, ); if (typeof keyEvaluation !== 'string') { throw error; } return keyEvaluation; } /** * Check if a JSX element has nested children or if it's a simple text node. * * Tries to mimic hasChildren function from React i18next: * see https://github.com/i18next/react-i18next/blob/8b6caf105/src/Trans.js#L6 * * @param path node path of the JSX element to check * @returns whether the node has nested children */ function hasChildren( path: BabelCore.NodePath, ): boolean { const children = path.get('children').filter((path) => { // Filter out empty JSX expression containers // (they do not count, even if they contain comments) if (path.isJSXExpressionContainer()) { const expression = path.get('expression'); return !expression.isJSXEmptyExpression(); } return true; }); if (children.length === 0) return false; if (1 < children.length) return true; const child = children[0]; if (child.isJSXExpressionContainer()) { let expression = child.get('expression'); if (expression.isIdentifier()) { const resolvedExpression = resolveIdentifier(expression); if (resolvedExpression === null) { // We weren't able to resolve the identifier. We consider this as // an absence of children, but it isn't very relevant anyways // because the extraction is very likely to fail later on. return false; } expression = resolvedExpression; } // If the expression is a string, we have an interpolation like {"foo"} // The only other valid interpolation would be {{myVar}} but apparently, // it is considered as a nested child. return typeof evaluateIfConfident(expression) !== 'string'; } return false; } /** * Format the key of a JSX element. * * @param path node path of the JSX element to format. * @param index the current index of the node being parsed. * @param config plugin configuration. * @returns key corresponding to the JSX element. */ function formatJSXElementKey( path: BabelCore.NodePath, index: number, config: Config, ): string { const openingElement = path.get('openingElement'); const closingElement = path.get('closingElement'); let resultTagName = `${index}`; // Tag name we will use in the exported file const tagName = openingElement.get('name'); if ( openingElement.get('attributes').length === 0 && tagName.isJSXIdentifier() && config.transKeepBasicHtmlNodesFor.includes(tagName.node.name) && !hasChildren(path) ) { // The tag name should not be transformed to an index resultTagName = tagName.node.name; if (closingElement.node === null) { // opening tag without closing tag (e.g.
) return `<${resultTagName}/>`; } } // it's nested. let's recurse. return `<${resultTagName}>${parseTransComponentKeyFromChildren( path, config, )}`; } /** * Given the node path of a Trans component, try to extract its key from its * children. * @param path node path of the Trans component. * @returns the component key if it was found. * @throws ExtractionError if the extraction did not succeed. */ function parseTransComponentKeyFromChildren( path: BabelCore.NodePath, config: Config, ): string { const transComponentExtractionError = new ExtractionError( `Couldn't evaluate i18next key in Trans component. You should either ` + `set the i18nKey attribute to an evaluable value, or make the Trans ` + `component content evaluable or skip the line using a skip comment ` + `(/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + `${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`, path, ); let children = path.get('children'); let result = ''; // Filter out JSXText nodes that only consist of whitespaces with one or // more linefeeds. Such node do not count for the indices. children = children.filter((child) => { return !( child.isJSXText() && child.node.value.trim() === '' && child.node.value.includes('\n') ); }); // Filter out empty containers. They do not affect indices. children = children.filter((p) => { if (!p.isJSXExpressionContainer()) return true; const expr = p.get('expression'); return !expr.isJSXEmptyExpression(); }); // We can then iterate on the children. for (let [i, child] of children.entries()) { if (child.isJSXExpressionContainer()) { // We have an expression container: {…} const expression = child.get('expression'); const evaluation = evaluateIfConfident(expression); if (evaluation !== null && typeof evaluation === 'string') { // We have an evaluable JSX expression like {'hello'} result += evaluation.toString(); continue; } if (expression.isObjectExpression()) { // We have an expression like {{name}} or {{name: userName}} const it = iterateObjectExpression(expression); const key0 = it.next().value; if (!key0 || !it.next().done) { // Probably got empty object expression like {{}} // or {{foo,bar}} throw transComponentExtractionError; } result += `{{${key0[0]}}}`; continue; } if (expression.isIdentifier()) { // We have an identifier like {myPartialComponent} // We try to find the latest declaration and substitute the identifier. const declarationExpression = resolveIdentifier(expression); const evaluation = evaluateIfConfident(declarationExpression); if (evaluation !== null) { // It could be evaluated, it's probably something like 'hello' result += evaluation; continue; } else if ( declarationExpression !== null && declarationExpression.isJSXElement() ) { // It's a JSX element. Let's act as if it was inline and move along. child = declarationExpression; } else { throw transComponentExtractionError; } } } if (child.isJSXText()) { // Simple JSX text. result += // Let's sanitize the value a bit. child.node.value // Strip line returns at start .replace(/^\s*(\r?\n)+\s*/gm, '') // Strip line returns at end .replace(/\s*(\r?\n)+\s*$/gm, '') // Replace other line returns with one space .replace(/\s*(\r?\n)+\s*/gm, ' '); continue; } if (child.isJSXElement()) { // got a JSX element. result += formatJSXElementKey(child, i, config); continue; } } return result; } /** * Parse `Trans` component to extract all its translation keys and i18next * options. * * @param path: node path of Trans JSX element. * @param config: plugin configuration * @param commentHints: parsed comment hints * @param skipCheck: set to true if you know that the JSXElement * already is a Trans component. */ export default function extractTransComponent( path: BabelCore.NodePath, config: Config, commentHints: CommentHint[] = [], skipCheck = false, ): ExtractedKey[] { if (getCommentHintForPath(path, 'DISABLE', commentHints)) return []; if (!skipCheck && !isTransComponent(path)) return []; const keyEvaluationFromAttribute = parseTransComponentKeyFromAttributes( path, ); const keyEvaluationFromChildren = parseTransComponentKeyFromChildren( path, config, ); const parsedOptions = parseTransComponentOptions(path, commentHints); if (parsedOptions.defaultValue === null) { parsedOptions.defaultValue = keyEvaluationFromChildren; } return [ { key: keyEvaluationFromAttribute || keyEvaluationFromChildren, parsedOptions, sourceNodes: [path.node], extractorName: extractTransComponent.name, }, ]; }