UNPKG

babel-plugin-i18next-extract

Version:

Statically extract translation keys from i18next application.

1,401 lines (1,326 loc) 69.8 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var nodePath = require('path'); var BabelTypes = require('@babel/types'); var fs = require('fs'); var deepmerge = require('deepmerge'); var stringify = require('json-stable-stringify'); var i18next = require('i18next'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var nodePath__namespace = /*#__PURE__*/_interopNamespaceDefault(nodePath); var BabelTypes__namespace = /*#__PURE__*/_interopNamespaceDefault(BabelTypes); var i18next__namespace = /*#__PURE__*/_interopNamespaceDefault(i18next); /** * Comment Hint without line location information. */ /** * Line intervals */ /** * Comment Hint with line intervals information. */ const COMMENT_HINT_PREFIX = "i18next-extract-"; const COMMENT_HINTS_KEYWORDS = { DISABLE: { LINE: COMMENT_HINT_PREFIX + "disable-line", NEXT_LINE: COMMENT_HINT_PREFIX + "disable-next-line", SECTION_START: COMMENT_HINT_PREFIX + "disable", SECTION_STOP: COMMENT_HINT_PREFIX + "enable" }, NAMESPACE: { LINE: COMMENT_HINT_PREFIX + "mark-ns-line", NEXT_LINE: COMMENT_HINT_PREFIX + "mark-ns-next-line", SECTION_START: COMMENT_HINT_PREFIX + "mark-ns-start", SECTION_STOP: COMMENT_HINT_PREFIX + "mark-ns-stop" }, CONTEXT: { LINE: COMMENT_HINT_PREFIX + "mark-context-line", NEXT_LINE: COMMENT_HINT_PREFIX + "mark-context-next-line", SECTION_START: COMMENT_HINT_PREFIX + "mark-context-start", SECTION_STOP: COMMENT_HINT_PREFIX + "mark-context-stop" }, PLURAL: { LINE: COMMENT_HINT_PREFIX + "mark-plural-line", NEXT_LINE: COMMENT_HINT_PREFIX + "mark-plural-next-line", SECTION_START: COMMENT_HINT_PREFIX + "mark-plural-start", SECTION_STOP: COMMENT_HINT_PREFIX + "mark-plural-stop" } }; /** * Given a Babel Comment, extract BaseCommentHints. * @param comment babel comment * @yields Comment hint without line interval information. */ function* extractCommentHintsFromBabelComment(comment) { for (const line of comment.value.split(/\r?\n/)) { const trimmedValue = line.trim(); const keyword = trimmedValue.split(/\s+/)[0]; const value = trimmedValue.split(/\s+(.+)/)[1] || ""; for (const [commentHintType, commentHintKeywords] of Object.entries(COMMENT_HINTS_KEYWORDS)) { for (const [commentHintScope, commentHintKeyword] of Object.entries(commentHintKeywords)) { if (keyword === commentHintKeyword) { yield { type: commentHintType, scope: commentHintScope, value, comment }; } } } } } /** * Given an array of comment hints, compute their intervals. * @param commentHints comment hints without line intervals information. * @returns Comment hints with line interval information. */ function computeCommentHintsIntervals(commentHints) { const result = Array(); for (const commentHint of commentHints) { if (!commentHint.comment.loc) continue; if (commentHint.scope === "LINE") { result.push({ startLine: commentHint.comment.loc.start.line, stopLine: commentHint.comment.loc.start.line, ...commentHint }); } if (commentHint.scope === "NEXT_LINE") { result.push({ startLine: commentHint.comment.loc.end.line + 1, stopLine: commentHint.comment.loc.end.line + 1, ...commentHint }); } if (commentHint.scope === "SECTION_START") { result.push({ startLine: commentHint.comment.loc.start.line, stopLine: Infinity, ...commentHint }); } if (commentHint.scope === "SECTION_STOP") { for (const res of result) { if (res.type === commentHint.type && res.scope === "SECTION_START" && res.stopLine === Infinity) { res.stopLine = commentHint.comment.loc.start.line; } } } } return result; } /** * Given Babel comments, extract the comment hints. * @param comments Babel comments (ordered by line) */ function parseCommentHints(comments) { const baseCommentHints = Array(); for (const comment of comments) { baseCommentHints.push(...extractCommentHintsFromBabelComment(comment)); } return computeCommentHintsIntervals(baseCommentHints); } /** * Find comment hint of a given type that applies to a Babel node path. * @param path babel node path * @param commentHintType Type of comment hint to look for. * @param commentHints All the comment hints, as returned by parseCommentHints function. */ function getCommentHintForPath(path, commentHintType, commentHints) { if (!path.node.loc) return null; const nodeLine = path.node.loc.start.line; for (const commentHint of commentHints) { if (commentHint.type === commentHintType && commentHint.startLine <= nodeLine && nodeLine <= commentHint.stopLine) { return commentHint; } } return null; } function resolveIfRelative(path) { if (path.startsWith(".")) { return nodePath.resolve(path); } return path; } function coalesce(v, defaultVal) { return v === undefined ? defaultVal : v; } /** * Given Babel options, return an initialized Config object. * * @param opts plugin options given by Babel */ function parseConfig(opts) { const defaultLocales = ["en"]; const customTransComponents = coalesce(opts.customTransComponents, []); const customUseTranslationHooks = coalesce(opts.customUseTranslationHooks, []); return { locales: coalesce(opts.locales, defaultLocales), defaultNS: coalesce(opts.defaultNS, "translation"), pluralSeparator: coalesce(opts.pluralSeparator, "_"), contextSeparator: coalesce(opts.contextSeparator, "_"), keySeparator: coalesce(opts.keySeparator, "."), nsSeparator: coalesce(opts.nsSeparator, ":"), // From react-i18next: https://github.com/i18next/react-i18next/blob/90f0e44ac2710ae422f1e8b0270de95fedc6429c/react-i18next.js#L334 transKeepBasicHtmlNodesFor: coalesce(opts.transKeepBasicHtmlNodesFor, ["br", "strong", "i", "p"]), compatibilityJSON: coalesce(opts.compatibilityJSON, null), i18nextInstanceNames: coalesce(opts.i18nextInstanceNames, ["i18next", "i18n"]), tFunctionNames: coalesce(opts.tFunctionNames, ["t"]), defaultContexts: coalesce(opts.defaultContexts, ["", "male", "female"]), outputPath: typeof opts.outputPath === "function" ? opts.outputPath : coalesce(opts.outputPath, "./extractedTranslations/{{locale}}/{{ns}}.json"), defaultValue: coalesce(opts.defaultValue, ""), useI18nextDefaultValue: coalesce(opts.useI18nextDefaultValue, defaultLocales), useI18nextDefaultValueForDerivedKeys: coalesce(opts.useI18nextDefaultValueForDerivedKeys, false), keyAsDefaultValue: coalesce(opts.keyAsDefaultValue, false), keyAsDefaultValueForDerivedKeys: coalesce(opts.keyAsDefaultValueForDerivedKeys, true), discardOldKeys: coalesce(opts.discardOldKeys, false), jsonSpace: coalesce(opts.jsonSpace, 2), customTransComponents, customUseTranslationHooks, excludes: coalesce(opts.excludes, ["^(../)*node_modules/"]), cache: { absoluteCustomTransComponents: customTransComponents.map(([sourceModule, importName]) => [resolveIfRelative(sourceModule), importName]), absoluteCustomHooks: customUseTranslationHooks.map(([sourceModule, importName]) => [resolveIfRelative(sourceModule), importName]) } }; } const PLUGIN_NAME = "babel-plugin-i18next-extract"; /** * Generic class thrown by exporters in case of error. */ class ExportError extends Error {} /** * Thrown by exporters when an existing value in a translation * file is incompatible with the value we're trying to set. * * For instance, if the translation file contains a deep key named `foo.bar` * but we extracted a (not deep) `foo` key, this error may be thrown. */ class ConflictError extends ExportError {} /** * Interface implented by exporters. */ /** * JSONv* values can be any valid value for JSON file. * * See i18next's "returnObjects" option. */ /** * Content of a JSONv* file. */ /** * Check whether a JsonVxValue is a plain object. */ function jsonVxValueIsObject(val) { return typeof val === "object" && val !== null && !Array.isArray(val); } /** * Add a key recursively to a JSONv4 file. * * @param fileContent JSONv* file content * @param keyPath keyPath of the key to add * @param cleanKey key without path * @param value Value to set for the key. */ function recursiveAddKey(fileContent, keyPath, cleanKey, value) { if (keyPath.length === 0) { return { ...fileContent, [cleanKey]: value }; } const currentKeyPath = keyPath[0]; let current = fileContent[currentKeyPath]; if (current === undefined) { current = {}; } else if (!jsonVxValueIsObject(current)) { throw new ConflictError(); } return { ...fileContent, [currentKeyPath]: recursiveAddKey(current, keyPath.slice(1), cleanKey, value) }; } const exporter = { init: () => { return { whitespacesBefore: "", whitespacesAfter: "\n", content: {} }; }, parse: ({ content }) => { const whitespacesBeforeMatch = content.match(/^(\s*)/); const whitespacesAfterMatch = content.match(/(\s*)$/); return { whitespacesBefore: whitespacesBeforeMatch === null ? "" : whitespacesBeforeMatch[0], whitespacesAfter: whitespacesAfterMatch === null ? "" : whitespacesAfterMatch[0], content: JSON.parse(content) }; }, stringify: ({ config, file }) => { return file.whitespacesBefore + stringify(file.content, { space: config.jsonSpace }) + file.whitespacesAfter; }, getKey: ({ file, keyPath, cleanKey }) => { let current = file.content; for (const p of keyPath) { const val = current[p]; if (val === undefined) { return undefined; } else if (!jsonVxValueIsObject(val)) { throw new ConflictError(); } current = val; } return current[cleanKey]; }, addKey: params => { const { key, file, value } = params; return { ...file, content: recursiveAddKey(file.content, key.keyPath, key.cleanKey, value) }; } }; /** * An instance of exporter cache. * * See createExporterCache for details. */ /** * This creates a new empty cache for the exporter. * * The cache is required by the exporter and is used to merge the translations * from the original translation file. It will be mutated by the exporter * and the same instance must be given untouched across export calls. */ function createExporterCache() { return { originalTranslationFiles: {}, currentTranslationFiles: {} }; } /** * Load a translation file. */ function loadTranslationFile( // eslint-disable-next-line @typescript-eslint/no-explicit-any exporter, config, filePath) { let content; try { content = fs.readFileSync(filePath, { encoding: "utf8" }); } catch (err) { if (err !== null && typeof err == "object" && err.code === "ENOENT") return exporter.init({ config }); throw err; } return exporter.parse({ config, content }); } /** * Get the default value for a key. */ function getDefaultValue(key, locale, config // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { let defaultValue = config.defaultValue; const keyAsDefaultValueEnabled = config.keyAsDefaultValue === true || Array.isArray(config.keyAsDefaultValue) && config.keyAsDefaultValue.includes(locale); const keyAsDefaultValueForDerivedKeys = config.keyAsDefaultValueForDerivedKeys; if (keyAsDefaultValueEnabled && (keyAsDefaultValueForDerivedKeys || !key.isDerivedKey)) { defaultValue = key.cleanKey; } const useI18nextDefaultValueEnabled = config.useI18nextDefaultValue === true || Array.isArray(config.useI18nextDefaultValue) && config.useI18nextDefaultValue.includes(locale); const useI18nextDefaultValueForDerivedKeys = config.useI18nextDefaultValueForDerivedKeys; if (useI18nextDefaultValueEnabled && key.parsedOptions.defaultValue !== null && (useI18nextDefaultValueForDerivedKeys || !key.isDerivedKey)) { defaultValue = key.parsedOptions.defaultValue; } return defaultValue; } /** * Exports all given translation keys as JSON. * * @param keys: translation keys to export * @param locale: the locale to export * @param config: plugin configuration * @param cache: cache instance to use (see createExporterCache) */ function exportTranslationKeys(keys, locale, config, cache) { const keysPerFilepath = {}; const exporter$1 = exporter; for (const key of keys) { // Figure out in which path each key should go. const filePath = typeof config.outputPath === "function" ? config.outputPath(locale, key.ns) : config.outputPath.replace("{{locale}}", locale).replace("{{ns}}", key.ns); keysPerFilepath[filePath] = [...(keysPerFilepath[filePath] || []), key]; } for (const [filePath, keysForFilepath] of Object.entries(keysPerFilepath)) { var _cache$originalTransl; cache.originalTranslationFiles[filePath] = deepmerge((_cache$originalTransl = cache.originalTranslationFiles[filePath]) !== null && _cache$originalTransl !== void 0 ? _cache$originalTransl : {}, loadTranslationFile(exporter$1, config, filePath), { // Overwrites the existing array values completely rather than concatenating them arrayMerge: (dest, source) => source }); const originalTranslationFile = cache.originalTranslationFiles[filePath]; let translationFile = cache.currentTranslationFiles[filePath] || (config.discardOldKeys ? exporter$1.init({ config }) : originalTranslationFile); for (const k of keysForFilepath) { const previousValue = exporter$1.getKey({ config, file: originalTranslationFile, keyPath: k.keyPath, cleanKey: k.cleanKey }); translationFile = exporter$1.addKey({ config, file: translationFile, key: k, value: previousValue === undefined ? getDefaultValue(k, locale, config) : previousValue }); } cache.currentTranslationFiles[filePath] = translationFile; // Finally do the export const directoryPath = nodePath.dirname(filePath); fs.mkdirSync(directoryPath, { recursive: true }); fs.writeFileSync(filePath, exporter$1.stringify({ config, file: translationFile }), { encoding: "utf8" }); } } /** * Error thrown in case extraction of a node failed. */ class ExtractionError extends Error { constructor(message, node) { super(message); this.nodePath = node; } } /** * Given a value, if the value is an array, return the first * item of the array. Otherwise, return the value. * * This is mainly useful to parse namespaces which can be strings * as well as array of strings. */ function getFirstOrNull(val) { if (Array.isArray(val)) val = val[0]; return val === undefined ? null : val; } /** * Given comment hints and a path, infer every I18NextOption we can from the comment hints. * @param path path on which the comment hints should apply * @param commentHints parsed comment hints * @returns every parsed option that could be infered. */ function parseI18NextOptionsFromCommentHints(path, commentHints) { const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints); const contextCommentHint = getCommentHintForPath(path, "CONTEXT", commentHints); const pluralCommentHint = getCommentHintForPath(path, "PLURAL", commentHints); const res = {}; if (nsCommentHint !== null) { res.ns = nsCommentHint.value; } if (contextCommentHint !== null) { if (["", "enable"].includes(contextCommentHint.value)) { res.contexts = true; } else if (contextCommentHint.value === "disable") { res.contexts = false; } else { try { const val = JSON.parse(contextCommentHint.value); if (Array.isArray(val)) res.contexts = val;else res.contexts = [contextCommentHint.value]; } catch { res.contexts = [contextCommentHint.value]; } } } if (pluralCommentHint !== null) { if (pluralCommentHint.value === "disable") { res.hasCount = false; } else { res.hasCount = true; } } return res; } /** * Improved version of BabelCore `referencesImport` function that also tries to detect wildcard * imports. */ function referencesImport(nodePath, moduleSource, importName) { if (nodePath.referencesImport(moduleSource, importName)) return true; if (nodePath.isMemberExpression() || nodePath.isJSXMemberExpression()) { const obj = nodePath.get("object"); const prop = nodePath.get("property"); if (Array.isArray(obj) || Array.isArray(prop) || !prop.isIdentifier() && !prop.isJSXIdentifier()) return false; return obj.referencesImport(moduleSource, "*") && prop.node.name === importName; } return false; } /** * Whether a class-instance function call expression matches a known method * @param nodePath: node path to evaluate * @param parentNames: list for any class-instance names to match * @param childName specific function from parent module to match */ function referencesChildIdentifier(nodePath, parentNames, childName) { if (!nodePath.isMemberExpression()) return false; const obj = nodePath.get("object"); if (!obj.isIdentifier()) return false; const prop = nodePath.get("property"); if (Array.isArray(prop) || !prop.isIdentifier()) return false; return parentNames.includes(obj.node.name) && prop.node.name === childName; } /** * Evaluates a node path if it can be evaluated with confidence. * * @param path: node path to evaluate * @returns null if the node path couldn't be evaluated */ function evaluateIfConfident( // eslint-disable-next-line @typescript-eslint/no-explicit-any path // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { if (!path || !path.node) { return null; } const evaluation = path.evaluate(); if (evaluation.confident) { return evaluation.value; } return null; } /** * Generator that iterates on all keys in an object expression. * @param path the node path of the object expression * @param key the key to find in the object expression. * @yields [evaluated key, node path of the object expression property] */ function* iterateObjectExpression(path) { const properties = path.get("properties"); for (const prop of properties) { const keyPath = prop.get("key"); if (Array.isArray(keyPath)) continue; let keyEvaluation = null; if (keyPath.isLiteral()) { keyEvaluation = evaluateIfConfident(keyPath); } else if (keyPath.isIdentifier()) { keyEvaluation = keyPath.node.name; } else { continue; } yield [keyEvaluation, prop]; } } /** * Try to find a key in an object expression. * @param path the node path of the object expression * @param key the key to find in the object expression. * @returns the corresponding node or null if it wasn't found */ function findKeyInObjectExpression(path, key) { for (const [keyEvaluation, prop] of iterateObjectExpression(path)) { if (keyEvaluation === key) return prop; } return null; } /** * Find a JSX attribute given its name. * @param path path of the jsx attribute * @param name name of the attribute to look for * @return The JSX attribute corresponding to the given name, or null if no * attribute with this name could be found. */ function findJSXAttributeByName(path, name) { const openingElement = path.get("openingElement"); const attributes = openingElement.get("attributes"); for (const attribute of attributes) { if (!attribute.isJSXAttribute()) continue; const attributeName = attribute.get("name"); if (!attributeName.isJSXIdentifier()) continue; if (name === attributeName.node.name) return attribute; } return null; } /** * Attempt to find the latest assigned value for a given identifier. * * For instance, given the following code: * const foo = 'bar'; * console.log(foo); * * resolveIdentifier(fooNodePath) should return the 'bar' literal. * * Obviously, this will only work in quite simple cases. * * @param nodePath: node path to resolve * @return the resolved expression or null if it could not be resolved. */ function resolveIdentifier(nodePath) { const bindings = nodePath.scope.bindings[nodePath.node.name]; if (!bindings) return null; const declarationExpressions = [...(bindings.path.isVariableDeclarator() ? [bindings.path.get("init")] : []), ...bindings.constantViolations.filter(p => p.isAssignmentExpression()).map(p => p.get("right"))]; if (declarationExpressions.length === 0) return null; const latestDeclarator = declarationExpressions[declarationExpressions.length - 1]; if (Array.isArray(latestDeclarator)) return null; if (!latestDeclarator.isExpression()) return null; return latestDeclarator; } /** * Check whether a given node is a custom import. * * @param absoluteNodePaths: list of possible custom nodes, with their source * modules and import names. * @param path: node path to check * @param name: node name to check * @returns true if the given node is a match. */ function isCustomImportedNode(absoluteNodePaths, path, name) { return absoluteNodePaths.some(([sourceModule, importName]) => { if (nodePath.isAbsolute(sourceModule)) { let relativeSourceModulePath = nodePath.relative(nodePath.dirname(path.state.filename), sourceModule); if (!relativeSourceModulePath.startsWith(".")) { relativeSourceModulePath = "." + nodePath.sep + relativeSourceModulePath; } // Absolute path to the source module, let's try a relative path first. if (referencesImport(name, relativeSourceModulePath, importName)) { return true; } } return referencesImport(name, sourceModule, importName); }); } /** * Find the aliased t function name (after being destructured). * If the destructure `t` function is not aliased, will return the identifier name as it is. * * For instance, given the following code: * const { t: tCommon } = useTranslation('common'); * return <p>{tCommon('key1')}<p> * * // or with pluginOptions.tFunctionNames = ["myT"] * const { myT: tCommon } = useTranslation('common'); * return <p>{tCommon('key1')}<p> * * getAliasedTBindingName(nodePath) should return 'tCommon' instead of t or myT * * @param nodePath: node path to resolve * @param tFunctionNames: possible names for the (unaliased) t function * @return the resolved t binding name, returning the alias if needed */ function getAliasedTBindingName(path, tFunctionNames) { const properties = path.get("properties"); const propertiesArray = Array.isArray(properties) ? properties : [properties]; for (const property of propertiesArray) { if (property.isObjectProperty()) { const key = property.node.key; const value = property.node.value; if (key.type === "Identifier" && value.type === "Identifier" && tFunctionNames.includes(key.name)) { return value.name; } } } return tFunctionNames.find(name => path.scope.bindings[name]); } /** * 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) { 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, commentHints) { const res = { contexts: false, hasCount: false, keyPrefix: null, 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 = nsAttr.get("value"); if (value.isJSXExpressionContainer()) value = value.get("expression"); res.ns = getFirstOrNull(evaluateIfConfident(value)); } const defaultsAttr = findJSXAttributeByName(path, "defaults"); if (defaultsAttr) { let value = 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) { 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) { 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, index, config) { 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. <br />) return `<${resultTagName}/>`; } } // it's nested. let's recurse. return `<${resultTagName}>${parseTransComponentKeyFromChildren(path, config)}</${resultTagName}>`; } /** * 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, config) { 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. */ function extractTransComponent(path, config, commentHints = [], skipCheck = false) { 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 }]; } /** * Extract custom Trans components. * * @param path: node path of potential custom Trans JSX element. * @param config: plugin configuration * @param commentHints: parsed comment hints */ function extractCustomTransComponent(path, config, commentHints = []) { if (getCommentHintForPath(path, "DISABLE", commentHints)) return []; if (!isCustomImportedNode(config.cache.absoluteCustomTransComponents, path, path.get("openingElement").get("name"))) return []; return extractTransComponent(path, config, commentHints, true); } /** * Check whether a given CallExpression path is a global call to the `t` * function. * * @param path: node path to check * @param config: plugin configuration * @returns true if the given call expression is indeed a call to i18next.t. */ function isSimpleTCall(path, config) { const callee = path.get("callee"); if (!callee.isIdentifier()) return false; return config.tFunctionNames.includes(callee.node.name); } /** * Parse options of a `t(…)` call. * @param path: NodePath representing the second argument of the `t()` call * (i.e. the i18next options) * @returns an object indicating whether the parsed options have context * and/or count. */ function parseTCallOptions(path) { const res = { contexts: false, hasCount: false, ns: null, keyPrefix: null, defaultValue: null }; if (!path) return res; // Try brutal evaluation of defaultValue first. const optsEvaluation = evaluateIfConfident(path); if (typeof optsEvaluation === "string") { res.defaultValue = optsEvaluation; } else if (path.isObjectExpression()) { // It didn't work. Let's try to parse as object expression. res.contexts = findKeyInObjectExpression(path, "context") !== null; res.hasCount = findKeyInObjectExpression(path, "count") !== null; const nsNode = findKeyInObjectExpression(path, "ns"); if (nsNode !== null && nsNode.isObjectProperty()) { const nsValueNode = nsNode.get("value"); const nsEvaluation = evaluateIfConfident(nsValueNode); res.ns = getFirstOrNull(nsEvaluation); } const defaultValueNode = findKeyInObjectExpression(path, "defaultValue"); if (defaultValueNode !== null && defaultValueNode.isObjectProperty()) { const defaultValueNodeValue = defaultValueNode.get("value"); res.defaultValue = evaluateIfConfident(defaultValueNodeValue); } const keyPrefixNode = findKeyInObjectExpression(path, "keyPrefix"); if (keyPrefixNode !== null && keyPrefixNode.isObjectProperty()) { const keyPrefixNodeValue = keyPrefixNode.get("value"); res.keyPrefix = evaluateIfConfident(keyPrefixNodeValue); } } return res; } /** * Given a call to the `t()` function, find the key and the options. * * @param path NodePath of the `t()` call. * @param commentHints parsed comment hints * @throws ExtractionError when the extraction failed for the `t` call. */ function extractTCall(path, commentHints) { const args = path.get("arguments"); const keyEvaluation = evaluateIfConfident(args[0]); const options = { ...parseTCallOptions(args[1]), ...parseI18NextOptionsFromCommentHints(path, commentHints) }; const result = []; const keys = Array.isArray(keyEvaluation) ? keyEvaluation : [keyEvaluation]; for (const key of keys) { if (typeof key !== "string") { throw new ExtractionError(`Couldn't evaluate i18next key. You should either make the key ` + `evaluable or skip the line using a skip comment (/* ` + `${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + `${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`, path); } result.push({ key, parsedOptions: options, sourceNodes: [path.node], extractorName: extractTFunction.name }); } return result; } /** * Parse a call expression (likely a call to a `t` function) to find its * translation keys and i18next options. * * @param path: node path of the t function call. * @param config: plugin configuration * @param commentHints: parsed comment hints * @param skipCheck: set to true if you know that the call expression arguments * already is a `t` function. */ function extractTFunction(path, config, commentHints = [], skipCheck = false) { if (getCommentHintForPath(path, "DISABLE", commentHints)) return []; if (!skipCheck && !isSimpleTCall(path, config)) return []; return extractTCall(path, commentHints); } /** * Check whether a given CallExpression path is a call to `useTranslation` hook. * @param path: node path to check * @returns true if the given call expression is indeed a call to * `useTranslation` */ function isUseTranslationHook(path) { const callee = path.get("callee"); return referencesImport(callee, "react-i18next", "useTranslation"); } /** * Parse `useTranslation()` hook to extract all its translation keys and * options. * @param path: useTranslation call node path. * @param config: plugin configuration * @param commentHints: parsed comment hints */ function extractUseTranslationHook(path, config, commentHints = [], skipCheck = false) { if (!skipCheck && !isUseTranslationHook(path)) return []; let ns; const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints); if (nsCommentHint) { // We got a comment hint, take its value as namespace. ns = nsCommentHint.value; } else { // Otherwise, try to get namespace from arguments. const namespaceArgument = path.get("arguments")[0]; ns = getFirstOrNull(evaluateIfConfident(namespaceArgument)); } const parentPath = path.parentPath; if (!parentPath.isVariableDeclarator()) return []; const id = parentPath.get("id"); const tBindingName = getAliasedTBindingName(id, config.tFunctionNames); if (!tBindingName) return []; const tBinding = id.scope.bindings[tBindingName]; if (!tBinding) return []; let keyPrefix = null; const optionsArgument = path.get("arguments")[1]; const options = getFirstOrNull(evaluateIfConfident(optionsArgument)); if (options) { keyPrefix = options.keyPrefix || keyPrefix; } let keys = Array(); for (const reference of tBinding.referencePaths) { var _reference$parentPath; if ((_reference$parentPath = reference.parentPath) !== null && _reference$parentPath !== void 0 && _reference$parentPath.isCallExpression() && reference.parentPath.get("callee") === reference) { keys = [...keys, ...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ // Add namespace if it was not explicitely set in t() call. ...k, parsedOptions: { ...k.parsedOptions, keyPrefix: k.parsedOptions.keyPrefix || keyPrefix, ns: k.parsedOptions.ns || ns } }))]; } } return keys.map(k => ({ ...k, sourceNodes: [path.node, ...k.sourceNodes], extractorName: extractUseTranslationHook.name })); } /** * Extract custom useTranslation hooks. * * @param path: node path of potential custom useTranslation hook calls. * @param config: plugin configuration * @param commentHints: parsed comment hints */ function extractCustomUseTranslationHook(path, config, commentHints = []) { if (getCommentHintForPath(path, "DISABLE", commentHints)) return []; if (!isCustomImportedNode(config.cache.absoluteCustomHooks, path, path.get("callee"))) { return []; } return extractUseTranslationHook(path, config, commentHints, true); } /** * Check whether a given CallExpression path is a call to `getFixedT()` * function. * @param path: node path to check * @param config: plugin configuration * @returns true if the given call expression is indeed a call to * `getFixedT` */ function isGetFixedTFunction(path, config) { const callee = path.get("callee"); return referencesChildIdentifier(callee, config.i18nextInstanceNames, "getFixedT"); } /** * Parse `getFixedT()` getter to extract all its translation keys and * options (see https://www.i18next.com/overview/api#getfixedt) * @param path: useTranslation call node path. * @param config: plugin configuration * @param commentHints: parsed comment hints */ function extractGetFixedTFunction(path, config, commentHints = []) { if (!isGetFixedTFunction(path, config)) return []; let ns; const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints); if (nsCommentHint) { // We got a comment hint, take its value as namespace. ns = nsCommentHint.value; } else { // Otherwise, try to get namespace from arguments. const namespaceArgument = path.get("arguments")[1]; ns = getFirstOrNull(evaluateIfConfident(namespaceArgument)); } const parentPath = path.parentPath; if (!parentPath.isVariableDeclarator()) return []; const id = parentPath.get("id"); if (!id.isIdentifier()) return []; const tBinding = id.scope.bindings[id.node.name]; if (!tBinding) return []; const keyPrefixArgument = path.get("arguments")[2]; const keyPrefix = getFirstOrNull(evaluateIfConfident(keyPrefixArgument)); let keys = Array(); for (const reference of tBinding.referencePaths) { var _reference$parentPath; if ((_reference$parentPath = reference.parentPath) !== null && _reference$parentPath !== void 0 && _reference$parentPath.isCallExpression() && reference.parentPath.get("callee") === reference) { keys = [...keys, ...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ // Add namespace if it was not explicitely set in t() call. ...k, parsedOptions: { ...k.parsedOptions, keyPrefix: k.parsedOptions.keyPrefix || keyPrefix, ns: k.parsedOptions.ns || ns } }))]; } } return keys.map(k => ({ ...k, sourceNodes: [path.node, ...k.sourceNodes], extractorName: extractGetFixedTFunction.name })); } /** * Check whether a given CallExpression path is a global call to `i18next.t` * function. * @param path: node path to check * @param config: plugin configuration * @returns true if the given call expression is indeed a call to i18next.t. */ function isI18nextTCall(path, config) { const callee = path.get("callee"); return referencesChildIdentifier(callee, config.i18nextInstanceNames, "t"); } /** * Parse a call expression (likely `i18next.t`) to find its translation keys * and i18next options. * * @param path: node path of the t function call. * @param config: plugin configuration * @param commentHints: parsed comment hints * @param skipCheck: set to true if you know that the call expression arguments * already is a `t` function. */ function extractI18nextInstance(path, config, commentHints = []) { if (getCommentHintForPath(path, "DISABLE", commentHints)) return []; if (!isI18nextTCall(path, config)) return []; return extractTFunction(path, config, commentHints, true).map(k => ({ ...k, sourceNodes: [path.node, ...k.sourceNodes], extractorName: extractI18nextInstance.name })); } /** * Check whether a given JSXElement is a Translation render prop. * @param path: node path to check * @returns true if the given element is indeed a `Translation` render prop. */ function isTranslationRenderProp(path) { const openingElement = path.get("openingElement"); return referencesImport(openingElement.get("name"), "react-i18next", "Translation"); } /** * Parse `Translation` render prop to extract all its translation keys and * options. * * @param path: node path of Translation JSX element. * @param config: plugin configuration * @param commentHints: parsed comment hints */ function extractTranslationRenderProp(path, config, commentHints = []) { if (!isTranslationRenderProp(path)) return []; let ns; const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints); if (nsCommentHint) { // We got a comment hint, take its value as namespace. ns = nsCommentHint.value; } else { // Try to parse ns property const nsAttr = findJSXAttributeByName(path, "ns"); if (nsAttr) { let value = nsAttr.get("value"); if (value.isJSXExpressionContainer()) value = value.get("expression"); ns = getFirstOrNull(evaluateIfConfident(value)); } } // We expect at least "<Translation>{(t) => …}</Translation> const expressionContainer = path.get("children").filter(p => p.isJSXExpressionContainer())[0]; if (!expressionContainer || !expressionContainer.isJSXExpressionContainer()) return []; const expression = expressionContainer.get("expression"); if (!expression.isArrowFunctionExpression()) return []; const tParam = expression.get("params")[0]; if (!tParam) return []; const tBinding = tParam.scope.bindings["t"]; if (!tBinding) return []; let keys = Array(); for (const reference of tBinding.referencePaths) { var _reference$parentPath; if ((_reference$parentPath = reference.parentPath) !== null && _reference$parentPath !== void 0 && _reference$parentPath.isCallExpression() && reference.parentPath.get("callee") === reference) { keys = [...keys, ...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ // Add namespace if it was not explicitely set in t() call. ...k, parsedOptions: { ...k.parsedOptions, ns: k.parsedOptions.ns || ns } }))]; } } return keys.map(k => ({ ...k, sourceNodes: [path.node, ...k.sourceNodes], extractorName: extractTranslationRenderProp.name })); } /** * Check whether a given node is a withTranslation call expression. * * @param path Node path to check * @returns true if the given node is an HOC call expression. */ function isWithTranslationHOCCallExpression(path) { return path.isCallExpression() && referencesImport(path.get("callee"), "react-i18next", "withTranslation"); } /** * If the given node is wrapped in a withTranslation call expression, * then return the call expression. * * @param path node path that is suspected to be part of a withTranslation call expression. * @returns withTranslation call expression if found, else null */ function findWithTranslationHOCCallExpressionInParents(path) { const callExpr = path.findParent(parentPath => { if (!parentPath.isCallExpression()) return false; const callee = parentPath.get("callee"); return isWithTranslationHOCCallExpression(callee); }); if (callExpr === null) { return null; } const callee = callExpr.get("callee"); if (Array.isArray(callee) || !callee.isCallExpression()) return null; return callee; } /** * Just like findWithTranslationHOCCallExpressionInParents, finds a withTranslation call * expression, but expects the callExpression to be curried in a "compose" function. * * e.g. compose(connect(), withTranslation())(MyComponent) * * @param path node path that is suspected to be part of a composed withTranslation call * expression. * @returns withTranslation call expression if found, else null */ function findWithTranslationHOCCallExpressionInCompose(path) { const composeFunctionNames = ["compose", "flow", "flowRight"]; let currentPath = path.parentPath; let withTranslationCallExpr = null; while ((_currentPath = currentPath) !== null && _currentPath !== void 0 && _currentPath.isCallExpression()) { var _currentPath; if (withTranslationCallExpr === null) { const args = currentPath.get("arguments"); withTranslationCallExpr = args.find(isWithTranslationHOCCallExpression) || null; } let callee = currentPath.get("callee"); if (callee.isMemberExpression()) { // If we have a member expression, we take the right operand // e.g. _.compose const result = callee.get("property"); if (!Array.isArray(result)) { callee = result; } } if (callee.isIdentifier() && composeFunctionNames.includes(callee.node.name)) { return withTranslationCallExpr; } currentPath = callee; } return null; } /** * Find whether a given function or class is wrapped with "withTranslation" HOC * somewhere. * @param path Function or class declaration node path. * @returns "withTranslation()()" call expression if found. Else null. */ function findWithTranslationHOCCallExpression(path) { let functionIdentifier = null; const ownId = path.get("id"); if (!Array.