babel-plugin-i18next-extract
Version:
Statically extract translation keys from i18next application.
1,401 lines (1,326 loc) • 69.8 kB
JavaScript
'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.