babel-plugin-i18next-extract
Advanced tools
Comparing version 0.1.0-alpha.1 to 0.1.0-alpha.2
@@ -6,57 +6,126 @@ import i18next from 'i18next'; | ||
const PLUGIN_NAME = 'babel-plugin-i18next-extract'; | ||
const COMMENT_DISABLE_LINE = 'i18next-extract-disable-line'; | ||
const COMMENT_DISABLE_NEXT_LINE = 'i18next-extract-disable-next-line'; | ||
const COMMENT_DISABLE_SECTION_START = 'i18next-extract-disable'; | ||
const COMMENT_DISABLE_SECTION_STOP = 'i18next-extract-enable'; | ||
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', | ||
}, | ||
}; | ||
/** | ||
* Computes line intervals where i18n extraction should be disabled. | ||
* @param comments Babel comments | ||
* @returns sections on which extraction should be disabled | ||
* Given a Babel BaseComment, try to extract a comment hint. | ||
* @param baseComment babel comment | ||
* @returns A comment hint without line interval information. | ||
*/ | ||
function computeCommentDisableIntervals(comments) { | ||
function extractCommentHint(baseComment) { | ||
const trimmedValue = baseComment.value.trim(); | ||
const keyword = trimmedValue.split(/\s+/)[0]; | ||
const value = trimmedValue.split(/\s+(.+)/)[1] || ''; | ||
for (let [commentHintType, commentHintKeywords] of Object.entries(COMMENT_HINTS_KEYWORDS)) { | ||
for (let [commentHintScope, commentHintKeyword] of Object.entries(commentHintKeywords)) { | ||
if (keyword === commentHintKeyword) { | ||
return { | ||
type: commentHintType, | ||
scope: commentHintScope, | ||
value, | ||
baseComment: baseComment, | ||
}; | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
/** | ||
* 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(); | ||
let lastDisableLine = null; | ||
for (const { value, loc } of comments) { | ||
const commentWords = value.split(/\s+/); | ||
if (commentWords.includes(COMMENT_DISABLE_LINE)) { | ||
result.push([loc.start.line, loc.start.line]); | ||
for (const commentHint of commentHints) { | ||
if (commentHint.scope === 'LINE') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line, | ||
stopLine: commentHint.baseComment.loc.start.line, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_NEXT_LINE)) { | ||
result.push([loc.end.line + 1, loc.end.line + 1]); | ||
if (commentHint.scope === 'NEXT_LINE') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line + 1, | ||
stopLine: commentHint.baseComment.loc.start.line + 1, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_SECTION_START)) { | ||
lastDisableLine = loc.start.line; | ||
if (commentHint.scope === 'SECTION_START') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line, | ||
stopLine: Infinity, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_SECTION_STOP) && | ||
lastDisableLine !== null) { | ||
result.push([lastDisableLine, loc.end.line]); | ||
lastDisableLine = null; | ||
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.baseComment.loc.start.line; | ||
} | ||
} | ||
} | ||
} | ||
if (lastDisableLine !== null) { | ||
result.push([lastDisableLine, Infinity]); | ||
} | ||
return result; | ||
} | ||
/** | ||
* Check if a given number is within any of the given intervals. | ||
* @param num number to check | ||
* @param intervals array of intervals | ||
* @returns true if num is within any of the intervals. | ||
* Given Babel comments, extract the comment hints. | ||
* @param baseComments Babel comments (ordered by line) | ||
*/ | ||
function numberIsWithinIntervals(num, intervals) { | ||
return intervals.some(([v0, v1]) => v0 <= num && num <= v1); | ||
function parseCommentHints(baseComments) { | ||
const result = Array(); | ||
for (const baseComment of baseComments) { | ||
const commentHint = extractCommentHint(baseComment); | ||
if (commentHint === null) { | ||
continue; | ||
} | ||
result.push(commentHint); | ||
} | ||
return computeCommentHintsIntervals(result); | ||
} | ||
/** | ||
* Check whether extraction is enables for a given path. | ||
* @param path: path to check | ||
* @param disableExtractionIntervals: line intervals where extraction is | ||
* disabled. | ||
* @returns true if the extraction is enabled for the given path. | ||
* 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 extractionIsEnabledForPath(path, disableExtractionIntervals) { | ||
return !(path.node.loc && | ||
numberIsWithinIntervals(path.node.loc.start.line, disableExtractionIntervals)); | ||
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; | ||
} | ||
@@ -81,7 +150,67 @@ | ||
} | ||
// AST Helpers | ||
/** | ||
* 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 { | ||
let val = JSON.parse(contextCommentHint.value); | ||
if (Array.isArray(val)) | ||
res.contexts = val; | ||
else | ||
res.contexts = [contextCommentHint.value]; | ||
} | ||
catch (err) { | ||
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(prop) || | ||
(!prop.isIdentifier() && !prop.isJSXIdentifier())) | ||
return false; | ||
return (obj.referencesImport(moduleSource, '*') && prop.node.name === importName); | ||
} | ||
return false; | ||
} | ||
/** | ||
* Evaluates a node path if it can be evaluated with confidence. | ||
* | ||
* @param nodePath: node path to evaluate | ||
* @param path: node path to evaluate | ||
* @returns null if the node path couldn't be evaluated | ||
@@ -189,26 +318,26 @@ */ | ||
function parseTCallOptions(path) { | ||
let hasContext = false; | ||
let hasCount = false; | ||
let ns = null; | ||
const res = { | ||
contexts: false, | ||
hasCount: false, | ||
ns: null, | ||
}; | ||
if (!path) | ||
return { hasContext, hasCount, ns }; | ||
return res; | ||
// Try brutal evaluation first. | ||
const optsEvaluation = evaluateIfConfident(path); | ||
if (optsEvaluation !== null) { | ||
hasContext = 'context' in optsEvaluation; | ||
hasCount = 'count' in optsEvaluation; | ||
res.contexts = 'context' in optsEvaluation; | ||
res.hasCount = 'count' in optsEvaluation; | ||
const evaluatedNamespace = optsEvaluation['ns']; | ||
ns = getFirstOrNull(evaluatedNamespace); | ||
return { hasContext, hasCount, ns }; | ||
res.ns = getFirstOrNull(evaluatedNamespace); | ||
} | ||
// It didn't work. Let's try to parse object expression keys. | ||
if (path.isObjectExpression()) { | ||
hasContext = findKeyInObjectExpression(path, 'context') !== null; | ||
hasCount = findKeyInObjectExpression(path, 'count') !== null; | ||
else if (path.isObjectExpression()) { | ||
// It didn't work. Let's try to parse object expression keys. | ||
res.contexts = findKeyInObjectExpression(path, 'context') !== null; | ||
res.hasCount = findKeyInObjectExpression(path, 'count') !== null; | ||
const nsNode = findKeyInObjectExpression(path, 'ns'); | ||
const nsNodeEvaluation = evaluateIfConfident(nsNode); | ||
ns = getFirstOrNull(nsNodeEvaluation); | ||
return { hasContext, hasCount, ns }; | ||
res.ns = getFirstOrNull(nsNodeEvaluation); | ||
} | ||
throw new ExtractionError("Couldn't evaluate i18next options. Please, provide options as an object expression."); | ||
return res; | ||
} | ||
@@ -219,5 +348,6 @@ /** | ||
* @param path NodePath of the `i18next.t` call. | ||
* @param commentHints parsed comment hints | ||
* @throws ExtractionError when the extraction failed for the `t` call. | ||
*/ | ||
function extractTCall(path) { | ||
function extractTCall(path, commentHints) { | ||
const args = path.get('arguments'); | ||
@@ -228,8 +358,11 @@ const keyEvaluation = evaluateIfConfident(args[0]); | ||
`evaluable or skip the line using a skip comment (/* ` + | ||
`${COMMENT_DISABLE_LINE} */ or /* ${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
} | ||
const parsedOptions = parseTCallOptions(args[1]); | ||
return { | ||
key: keyEvaluation, | ||
parsedOptions, | ||
parsedOptions: { | ||
...parseTCallOptions(args[1]), | ||
...parseI18NextOptionsFromCommentHints(path, commentHints), | ||
}, | ||
nodePath: path, | ||
@@ -244,12 +377,12 @@ }; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is disabled | ||
* @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, disableExtractionIntervals = [], skipCheck = false) { | ||
if (!extractionIsEnabledForPath(path, disableExtractionIntervals)) | ||
function extractTFunction(path, config, commentHints = [], skipCheck = false) { | ||
if (getCommentHintForPath(path, 'DISABLE', commentHints)) | ||
return []; | ||
if (!skipCheck && !isSimpleTCall(path, config)) | ||
return []; | ||
return [extractTCall(path)]; | ||
return [extractTCall(path, commentHints)]; | ||
} | ||
@@ -265,3 +398,3 @@ | ||
const callee = path.get('callee'); | ||
return callee.referencesImport('react-i18next', 'useTranslation'); | ||
return referencesImport(callee, 'react-i18next', 'useTranslation'); | ||
} | ||
@@ -273,10 +406,18 @@ /** | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractUseTranslationHook(path, config, disableExtractionIntervals = []) { | ||
function extractUseTranslationHook(path, config, commentHints = []) { | ||
if (!isUseTranslationHook(path)) | ||
return []; | ||
const namespaceArgument = path.get('arguments')[0]; | ||
const ns = getFirstOrNull(evaluateIfConfident(namespaceArgument)); | ||
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; | ||
@@ -294,3 +435,3 @@ if (!parentPath.isVariableDeclarator()) | ||
...keys, | ||
...extractTFunction(reference.parentPath, config, disableExtractionIntervals, true).map(k => ({ | ||
...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ | ||
// Add namespace if it was not explicitely set in t() call. | ||
@@ -316,5 +457,3 @@ ...k, | ||
const openingElement = path.get('openingElement'); | ||
return openingElement | ||
.get('name') | ||
.referencesImport('react-i18next', 'Translation'); | ||
return referencesImport(openingElement.get('name'), 'react-i18next', 'Translation'); | ||
} | ||
@@ -327,17 +466,23 @@ /** | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractTranslationRenderProp(path, config, disableExtractionIntervals = []) { | ||
function extractTranslationRenderProp(path, config, commentHints = []) { | ||
if (!isTranslationRenderProp(path)) | ||
return []; | ||
// Try to parse ns property | ||
let ns = null; | ||
const nsAttr = findJSXAttributeByName(path, 'ns'); | ||
if (nsAttr) { | ||
let value = nsAttr.get('value'); | ||
if (value.isJSXExpressionContainer()) | ||
value = value.get('expression'); | ||
ns = getFirstOrNull(evaluateIfConfident(value)); | ||
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> | ||
@@ -363,3 +508,3 @@ const expressionContainer = path | ||
...keys, | ||
...extractTFunction(reference.parentPath, config, disableExtractionIntervals, true).map(k => ({ | ||
...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ | ||
// Add namespace if it was not explicitely set in t() call. | ||
@@ -385,3 +530,3 @@ ...k, | ||
const openingElement = path.get('openingElement'); | ||
return openingElement.get('name').referencesImport('react-i18next', 'Trans'); | ||
return referencesImport(openingElement.get('name'), 'react-i18next', 'Trans'); | ||
} | ||
@@ -391,10 +536,13 @@ /** | ||
* @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) { | ||
let hasCount = false; | ||
let hasContext = false; | ||
let ns = null; | ||
function parseTransComponentOptions(path, commentHints) { | ||
const res = { | ||
contexts: false, | ||
hasCount: false, | ||
ns: null, | ||
}; | ||
const countAttr = findJSXAttributeByName(path, 'count'); | ||
hasCount = countAttr !== null; | ||
res.hasCount = countAttr !== null; | ||
const tOptionsAttr = findJSXAttributeByName(path, 'tOptions'); | ||
@@ -406,3 +554,4 @@ if (tOptionsAttr) { | ||
if (expression.isObjectExpression()) { | ||
hasContext = findKeyInObjectExpression(expression, 'context') !== null; | ||
res.contexts = | ||
findKeyInObjectExpression(expression, 'context') !== null; | ||
} | ||
@@ -416,8 +565,7 @@ } | ||
value = value.get('expression'); | ||
ns = getFirstOrNull(evaluateIfConfident(value)); | ||
res.ns = getFirstOrNull(evaluateIfConfident(value)); | ||
} | ||
return { | ||
hasContext, | ||
hasCount, | ||
ns, | ||
...res, | ||
...parseI18NextOptionsFromCommentHints(path, commentHints), | ||
}; | ||
@@ -436,4 +584,4 @@ } | ||
`make the i18nKey attribute evaluable or skip the line using a skip ` + | ||
`comment (/* ${COMMENT_DISABLE_LINE} */ or /* ` + | ||
`${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`comment (/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
const keyAttribute = findJSXAttributeByName(path, 'i18nKey'); | ||
@@ -462,3 +610,4 @@ if (!keyAttribute) | ||
`component content evaluable or skip the line using a skip comment ` + | ||
`(/* ${COMMENT_DISABLE_LINE} */ or /* ${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`(/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
let children = path.get('children'); | ||
@@ -577,7 +726,6 @@ let result = ''; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractTransComponent(path, config, disableExtractionIntervals = []) { | ||
if (!extractionIsEnabledForPath(path, disableExtractionIntervals)) | ||
function extractTransComponent(path, config, commentHints = []) { | ||
if (getCommentHintForPath(path, 'DISABLE', commentHints)) | ||
return []; | ||
@@ -588,3 +736,3 @@ if (!isTransComponent(path)) | ||
parseTransComponentKeyFromChildren(path); | ||
const parsedOptions = parseTransComponentOptions(path); | ||
const parsedOptions = parseTransComponentOptions(path, commentHints); | ||
return [ | ||
@@ -632,5 +780,5 @@ { | ||
* e.g. | ||
* ({'foo', {hasContext: false, hasCount: true}}, 'en') | ||
* ({'foo', {contexts: false, hasCount: true}}, 'en') | ||
* => ['foo', 'foo_plural'] | ||
* ({'bar', {hasContext: true, hasCount: true}}, 'en') | ||
* ({'bar', {contexts: ['male', 'female'], hasCount: true}}, 'en') | ||
* => ['foo_male', 'foo_male_plural', 'foo_female', 'foo_female_plural'] | ||
@@ -647,7 +795,10 @@ * | ||
let keys = [translationKey]; | ||
if (parsedOptions.hasContext) { | ||
if (parsedOptions.contexts !== false) { | ||
// Add all context suffixes | ||
// For instance, if key is "foo", may want | ||
// ["foo", "foo_male", "foo_female"] depending on defaultContexts value. | ||
keys = config.defaultContexts.map(v => { | ||
const contexts = Array.isArray(parsedOptions.contexts) | ||
? parsedOptions.contexts | ||
: config.defaultContexts; | ||
keys = contexts.map(v => { | ||
if (v === '') | ||
@@ -723,3 +874,4 @@ return translationKey; | ||
defaultValue: coalesce(opts.defaultValue, ''), | ||
useKeyAsDefaultValue: coalesce(opts.useKeyAsDefaultValue, false), | ||
keyAsDefaultValue: coalesce(opts.keyAsDefaultValue, false), | ||
keyAsDefaultValueForDerivedKeys: coalesce(opts.keyAsDefaultValueForDerivedKeys, true), | ||
exporterJsonSpace: coalesce(opts.exporterJsonSpace, 2), | ||
@@ -729,2 +881,4 @@ }; | ||
const PLUGIN_NAME = 'babel-plugin-i18next-extract'; | ||
class ExportError extends Error { | ||
@@ -788,5 +942,8 @@ } | ||
let defaultValue = config.defaultValue; | ||
if (config.useKeyAsDefaultValue === true || | ||
(Array.isArray(config.useKeyAsDefaultValue) && | ||
config.useKeyAsDefaultValue.includes(locale))) { | ||
const keyAsDefaultValueEnabled = config.keyAsDefaultValue === true || | ||
(Array.isArray(config.keyAsDefaultValue) && | ||
config.keyAsDefaultValue.includes(locale)); | ||
const keyAsDefaultValueForDerivedKeys = config.keyAsDefaultValueForDerivedKeys; | ||
if (keyAsDefaultValueEnabled && | ||
(keyAsDefaultValueForDerivedKeys || !k.isDerivedKey)) { | ||
defaultValue = k.cleanKey; | ||
@@ -823,7 +980,7 @@ } | ||
for (const key of keys) { | ||
if (extractedNodes.has(key.nodePath)) { | ||
if (extractedNodes.has(key.nodePath.node)) { | ||
// The node was already extracted. Skip it. | ||
continue; | ||
} | ||
extractedNodes.add(key.nodePath); | ||
extractedNodes.add(key.nodePath.node); | ||
state.I18NextExtract.extractedKeys.push(key); | ||
@@ -850,4 +1007,4 @@ } | ||
handleExtraction(path, state, collect => { | ||
collect(extractUseTranslationHook(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTFunction(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractUseTranslationHook(path, extractState.config, extractState.commentHints)); | ||
collect(extractTFunction(path, extractState.config, extractState.commentHints)); | ||
}); | ||
@@ -858,4 +1015,4 @@ }, | ||
handleExtraction(path, state, collect => { | ||
collect(extractTranslationRenderProp(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTransComponent(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTranslationRenderProp(path, extractState.config, extractState.commentHints)); | ||
collect(extractTransComponent(path, extractState.config, extractState.commentHints)); | ||
}); | ||
@@ -871,3 +1028,3 @@ }, | ||
extractedKeys: [], | ||
disableExtractionIntervals: [], | ||
commentHints: [], | ||
}; | ||
@@ -877,8 +1034,6 @@ }, | ||
const extractState = this.I18NextExtract; | ||
if (!extractState.extractedKeys) | ||
if (extractState.extractedKeys.length === 0) | ||
return; | ||
for (const locale of extractState.config.locales) { | ||
// eslint-disable-next-line no-console | ||
console.log(`Exporting locale: ${locale}.`); | ||
const derivedKeys = this.I18NextExtract.extractedKeys.reduce((accumulator, k) => [ | ||
const derivedKeys = extractState.extractedKeys.reduce((accumulator, k) => [ | ||
...accumulator, | ||
@@ -895,3 +1050,3 @@ ...computeDerivedKeys(k, locale, extractState.config), | ||
if (isFile(path.container)) { | ||
this.I18NextExtract.disableExtractionIntervals = computeCommentDisableIntervals(path.container.comments); | ||
this.I18NextExtract.commentHints = parseCommentHints(path.container.comments); | ||
} | ||
@@ -898,0 +1053,0 @@ path.traverse(Visitor, state); |
407
lib/index.js
@@ -10,57 +10,126 @@ 'use strict'; | ||
const PLUGIN_NAME = 'babel-plugin-i18next-extract'; | ||
const COMMENT_DISABLE_LINE = 'i18next-extract-disable-line'; | ||
const COMMENT_DISABLE_NEXT_LINE = 'i18next-extract-disable-next-line'; | ||
const COMMENT_DISABLE_SECTION_START = 'i18next-extract-disable'; | ||
const COMMENT_DISABLE_SECTION_STOP = 'i18next-extract-enable'; | ||
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', | ||
}, | ||
}; | ||
/** | ||
* Computes line intervals where i18n extraction should be disabled. | ||
* @param comments Babel comments | ||
* @returns sections on which extraction should be disabled | ||
* Given a Babel BaseComment, try to extract a comment hint. | ||
* @param baseComment babel comment | ||
* @returns A comment hint without line interval information. | ||
*/ | ||
function computeCommentDisableIntervals(comments) { | ||
function extractCommentHint(baseComment) { | ||
const trimmedValue = baseComment.value.trim(); | ||
const keyword = trimmedValue.split(/\s+/)[0]; | ||
const value = trimmedValue.split(/\s+(.+)/)[1] || ''; | ||
for (let [commentHintType, commentHintKeywords] of Object.entries(COMMENT_HINTS_KEYWORDS)) { | ||
for (let [commentHintScope, commentHintKeyword] of Object.entries(commentHintKeywords)) { | ||
if (keyword === commentHintKeyword) { | ||
return { | ||
type: commentHintType, | ||
scope: commentHintScope, | ||
value, | ||
baseComment: baseComment, | ||
}; | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
/** | ||
* 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(); | ||
let lastDisableLine = null; | ||
for (const { value, loc } of comments) { | ||
const commentWords = value.split(/\s+/); | ||
if (commentWords.includes(COMMENT_DISABLE_LINE)) { | ||
result.push([loc.start.line, loc.start.line]); | ||
for (const commentHint of commentHints) { | ||
if (commentHint.scope === 'LINE') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line, | ||
stopLine: commentHint.baseComment.loc.start.line, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_NEXT_LINE)) { | ||
result.push([loc.end.line + 1, loc.end.line + 1]); | ||
if (commentHint.scope === 'NEXT_LINE') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line + 1, | ||
stopLine: commentHint.baseComment.loc.start.line + 1, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_SECTION_START)) { | ||
lastDisableLine = loc.start.line; | ||
if (commentHint.scope === 'SECTION_START') { | ||
result.push({ | ||
startLine: commentHint.baseComment.loc.start.line, | ||
stopLine: Infinity, | ||
...commentHint, | ||
}); | ||
} | ||
if (commentWords.includes(COMMENT_DISABLE_SECTION_STOP) && | ||
lastDisableLine !== null) { | ||
result.push([lastDisableLine, loc.end.line]); | ||
lastDisableLine = null; | ||
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.baseComment.loc.start.line; | ||
} | ||
} | ||
} | ||
} | ||
if (lastDisableLine !== null) { | ||
result.push([lastDisableLine, Infinity]); | ||
} | ||
return result; | ||
} | ||
/** | ||
* Check if a given number is within any of the given intervals. | ||
* @param num number to check | ||
* @param intervals array of intervals | ||
* @returns true if num is within any of the intervals. | ||
* Given Babel comments, extract the comment hints. | ||
* @param baseComments Babel comments (ordered by line) | ||
*/ | ||
function numberIsWithinIntervals(num, intervals) { | ||
return intervals.some(([v0, v1]) => v0 <= num && num <= v1); | ||
function parseCommentHints(baseComments) { | ||
const result = Array(); | ||
for (const baseComment of baseComments) { | ||
const commentHint = extractCommentHint(baseComment); | ||
if (commentHint === null) { | ||
continue; | ||
} | ||
result.push(commentHint); | ||
} | ||
return computeCommentHintsIntervals(result); | ||
} | ||
/** | ||
* Check whether extraction is enables for a given path. | ||
* @param path: path to check | ||
* @param disableExtractionIntervals: line intervals where extraction is | ||
* disabled. | ||
* @returns true if the extraction is enabled for the given path. | ||
* 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 extractionIsEnabledForPath(path, disableExtractionIntervals) { | ||
return !(path.node.loc && | ||
numberIsWithinIntervals(path.node.loc.start.line, disableExtractionIntervals)); | ||
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; | ||
} | ||
@@ -85,7 +154,67 @@ | ||
} | ||
// AST Helpers | ||
/** | ||
* 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 { | ||
let val = JSON.parse(contextCommentHint.value); | ||
if (Array.isArray(val)) | ||
res.contexts = val; | ||
else | ||
res.contexts = [contextCommentHint.value]; | ||
} | ||
catch (err) { | ||
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(prop) || | ||
(!prop.isIdentifier() && !prop.isJSXIdentifier())) | ||
return false; | ||
return (obj.referencesImport(moduleSource, '*') && prop.node.name === importName); | ||
} | ||
return false; | ||
} | ||
/** | ||
* Evaluates a node path if it can be evaluated with confidence. | ||
* | ||
* @param nodePath: node path to evaluate | ||
* @param path: node path to evaluate | ||
* @returns null if the node path couldn't be evaluated | ||
@@ -193,26 +322,26 @@ */ | ||
function parseTCallOptions(path) { | ||
let hasContext = false; | ||
let hasCount = false; | ||
let ns = null; | ||
const res = { | ||
contexts: false, | ||
hasCount: false, | ||
ns: null, | ||
}; | ||
if (!path) | ||
return { hasContext, hasCount, ns }; | ||
return res; | ||
// Try brutal evaluation first. | ||
const optsEvaluation = evaluateIfConfident(path); | ||
if (optsEvaluation !== null) { | ||
hasContext = 'context' in optsEvaluation; | ||
hasCount = 'count' in optsEvaluation; | ||
res.contexts = 'context' in optsEvaluation; | ||
res.hasCount = 'count' in optsEvaluation; | ||
const evaluatedNamespace = optsEvaluation['ns']; | ||
ns = getFirstOrNull(evaluatedNamespace); | ||
return { hasContext, hasCount, ns }; | ||
res.ns = getFirstOrNull(evaluatedNamespace); | ||
} | ||
// It didn't work. Let's try to parse object expression keys. | ||
if (path.isObjectExpression()) { | ||
hasContext = findKeyInObjectExpression(path, 'context') !== null; | ||
hasCount = findKeyInObjectExpression(path, 'count') !== null; | ||
else if (path.isObjectExpression()) { | ||
// It didn't work. Let's try to parse object expression keys. | ||
res.contexts = findKeyInObjectExpression(path, 'context') !== null; | ||
res.hasCount = findKeyInObjectExpression(path, 'count') !== null; | ||
const nsNode = findKeyInObjectExpression(path, 'ns'); | ||
const nsNodeEvaluation = evaluateIfConfident(nsNode); | ||
ns = getFirstOrNull(nsNodeEvaluation); | ||
return { hasContext, hasCount, ns }; | ||
res.ns = getFirstOrNull(nsNodeEvaluation); | ||
} | ||
throw new ExtractionError("Couldn't evaluate i18next options. Please, provide options as an object expression."); | ||
return res; | ||
} | ||
@@ -223,5 +352,6 @@ /** | ||
* @param path NodePath of the `i18next.t` call. | ||
* @param commentHints parsed comment hints | ||
* @throws ExtractionError when the extraction failed for the `t` call. | ||
*/ | ||
function extractTCall(path) { | ||
function extractTCall(path, commentHints) { | ||
const args = path.get('arguments'); | ||
@@ -232,8 +362,11 @@ const keyEvaluation = evaluateIfConfident(args[0]); | ||
`evaluable or skip the line using a skip comment (/* ` + | ||
`${COMMENT_DISABLE_LINE} */ or /* ${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
} | ||
const parsedOptions = parseTCallOptions(args[1]); | ||
return { | ||
key: keyEvaluation, | ||
parsedOptions, | ||
parsedOptions: { | ||
...parseTCallOptions(args[1]), | ||
...parseI18NextOptionsFromCommentHints(path, commentHints), | ||
}, | ||
nodePath: path, | ||
@@ -248,12 +381,12 @@ }; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is disabled | ||
* @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, disableExtractionIntervals = [], skipCheck = false) { | ||
if (!extractionIsEnabledForPath(path, disableExtractionIntervals)) | ||
function extractTFunction(path, config, commentHints = [], skipCheck = false) { | ||
if (getCommentHintForPath(path, 'DISABLE', commentHints)) | ||
return []; | ||
if (!skipCheck && !isSimpleTCall(path, config)) | ||
return []; | ||
return [extractTCall(path)]; | ||
return [extractTCall(path, commentHints)]; | ||
} | ||
@@ -269,3 +402,3 @@ | ||
const callee = path.get('callee'); | ||
return callee.referencesImport('react-i18next', 'useTranslation'); | ||
return referencesImport(callee, 'react-i18next', 'useTranslation'); | ||
} | ||
@@ -277,10 +410,18 @@ /** | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractUseTranslationHook(path, config, disableExtractionIntervals = []) { | ||
function extractUseTranslationHook(path, config, commentHints = []) { | ||
if (!isUseTranslationHook(path)) | ||
return []; | ||
const namespaceArgument = path.get('arguments')[0]; | ||
const ns = getFirstOrNull(evaluateIfConfident(namespaceArgument)); | ||
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; | ||
@@ -298,3 +439,3 @@ if (!parentPath.isVariableDeclarator()) | ||
...keys, | ||
...extractTFunction(reference.parentPath, config, disableExtractionIntervals, true).map(k => ({ | ||
...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ | ||
// Add namespace if it was not explicitely set in t() call. | ||
@@ -320,5 +461,3 @@ ...k, | ||
const openingElement = path.get('openingElement'); | ||
return openingElement | ||
.get('name') | ||
.referencesImport('react-i18next', 'Translation'); | ||
return referencesImport(openingElement.get('name'), 'react-i18next', 'Translation'); | ||
} | ||
@@ -331,17 +470,23 @@ /** | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractTranslationRenderProp(path, config, disableExtractionIntervals = []) { | ||
function extractTranslationRenderProp(path, config, commentHints = []) { | ||
if (!isTranslationRenderProp(path)) | ||
return []; | ||
// Try to parse ns property | ||
let ns = null; | ||
const nsAttr = findJSXAttributeByName(path, 'ns'); | ||
if (nsAttr) { | ||
let value = nsAttr.get('value'); | ||
if (value.isJSXExpressionContainer()) | ||
value = value.get('expression'); | ||
ns = getFirstOrNull(evaluateIfConfident(value)); | ||
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> | ||
@@ -367,3 +512,3 @@ const expressionContainer = path | ||
...keys, | ||
...extractTFunction(reference.parentPath, config, disableExtractionIntervals, true).map(k => ({ | ||
...extractTFunction(reference.parentPath, config, commentHints, true).map(k => ({ | ||
// Add namespace if it was not explicitely set in t() call. | ||
@@ -389,3 +534,3 @@ ...k, | ||
const openingElement = path.get('openingElement'); | ||
return openingElement.get('name').referencesImport('react-i18next', 'Trans'); | ||
return referencesImport(openingElement.get('name'), 'react-i18next', 'Trans'); | ||
} | ||
@@ -395,10 +540,13 @@ /** | ||
* @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) { | ||
let hasCount = false; | ||
let hasContext = false; | ||
let ns = null; | ||
function parseTransComponentOptions(path, commentHints) { | ||
const res = { | ||
contexts: false, | ||
hasCount: false, | ||
ns: null, | ||
}; | ||
const countAttr = findJSXAttributeByName(path, 'count'); | ||
hasCount = countAttr !== null; | ||
res.hasCount = countAttr !== null; | ||
const tOptionsAttr = findJSXAttributeByName(path, 'tOptions'); | ||
@@ -410,3 +558,4 @@ if (tOptionsAttr) { | ||
if (expression.isObjectExpression()) { | ||
hasContext = findKeyInObjectExpression(expression, 'context') !== null; | ||
res.contexts = | ||
findKeyInObjectExpression(expression, 'context') !== null; | ||
} | ||
@@ -420,8 +569,7 @@ } | ||
value = value.get('expression'); | ||
ns = getFirstOrNull(evaluateIfConfident(value)); | ||
res.ns = getFirstOrNull(evaluateIfConfident(value)); | ||
} | ||
return { | ||
hasContext, | ||
hasCount, | ||
ns, | ||
...res, | ||
...parseI18NextOptionsFromCommentHints(path, commentHints), | ||
}; | ||
@@ -440,4 +588,4 @@ } | ||
`make the i18nKey attribute evaluable or skip the line using a skip ` + | ||
`comment (/* ${COMMENT_DISABLE_LINE} */ or /* ` + | ||
`${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`comment (/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
const keyAttribute = findJSXAttributeByName(path, 'i18nKey'); | ||
@@ -466,3 +614,4 @@ if (!keyAttribute) | ||
`component content evaluable or skip the line using a skip comment ` + | ||
`(/* ${COMMENT_DISABLE_LINE} */ or /* ${COMMENT_DISABLE_NEXT_LINE} */).`); | ||
`(/* ${COMMENT_HINTS_KEYWORDS.DISABLE.LINE} */ or /* ` + | ||
`${COMMENT_HINTS_KEYWORDS.DISABLE.NEXT_LINE} */).`); | ||
let children = path.get('children'); | ||
@@ -581,7 +730,6 @@ let result = ''; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
function extractTransComponent(path, config, disableExtractionIntervals = []) { | ||
if (!extractionIsEnabledForPath(path, disableExtractionIntervals)) | ||
function extractTransComponent(path, config, commentHints = []) { | ||
if (getCommentHintForPath(path, 'DISABLE', commentHints)) | ||
return []; | ||
@@ -592,3 +740,3 @@ if (!isTransComponent(path)) | ||
parseTransComponentKeyFromChildren(path); | ||
const parsedOptions = parseTransComponentOptions(path); | ||
const parsedOptions = parseTransComponentOptions(path, commentHints); | ||
return [ | ||
@@ -636,5 +784,5 @@ { | ||
* e.g. | ||
* ({'foo', {hasContext: false, hasCount: true}}, 'en') | ||
* ({'foo', {contexts: false, hasCount: true}}, 'en') | ||
* => ['foo', 'foo_plural'] | ||
* ({'bar', {hasContext: true, hasCount: true}}, 'en') | ||
* ({'bar', {contexts: ['male', 'female'], hasCount: true}}, 'en') | ||
* => ['foo_male', 'foo_male_plural', 'foo_female', 'foo_female_plural'] | ||
@@ -651,7 +799,10 @@ * | ||
let keys = [translationKey]; | ||
if (parsedOptions.hasContext) { | ||
if (parsedOptions.contexts !== false) { | ||
// Add all context suffixes | ||
// For instance, if key is "foo", may want | ||
// ["foo", "foo_male", "foo_female"] depending on defaultContexts value. | ||
keys = config.defaultContexts.map(v => { | ||
const contexts = Array.isArray(parsedOptions.contexts) | ||
? parsedOptions.contexts | ||
: config.defaultContexts; | ||
keys = contexts.map(v => { | ||
if (v === '') | ||
@@ -727,3 +878,4 @@ return translationKey; | ||
defaultValue: coalesce(opts.defaultValue, ''), | ||
useKeyAsDefaultValue: coalesce(opts.useKeyAsDefaultValue, false), | ||
keyAsDefaultValue: coalesce(opts.keyAsDefaultValue, false), | ||
keyAsDefaultValueForDerivedKeys: coalesce(opts.keyAsDefaultValueForDerivedKeys, true), | ||
exporterJsonSpace: coalesce(opts.exporterJsonSpace, 2), | ||
@@ -733,2 +885,4 @@ }; | ||
const PLUGIN_NAME = 'babel-plugin-i18next-extract'; | ||
class ExportError extends Error { | ||
@@ -792,5 +946,8 @@ } | ||
let defaultValue = config.defaultValue; | ||
if (config.useKeyAsDefaultValue === true || | ||
(Array.isArray(config.useKeyAsDefaultValue) && | ||
config.useKeyAsDefaultValue.includes(locale))) { | ||
const keyAsDefaultValueEnabled = config.keyAsDefaultValue === true || | ||
(Array.isArray(config.keyAsDefaultValue) && | ||
config.keyAsDefaultValue.includes(locale)); | ||
const keyAsDefaultValueForDerivedKeys = config.keyAsDefaultValueForDerivedKeys; | ||
if (keyAsDefaultValueEnabled && | ||
(keyAsDefaultValueForDerivedKeys || !k.isDerivedKey)) { | ||
defaultValue = k.cleanKey; | ||
@@ -827,7 +984,7 @@ } | ||
for (const key of keys) { | ||
if (extractedNodes.has(key.nodePath)) { | ||
if (extractedNodes.has(key.nodePath.node)) { | ||
// The node was already extracted. Skip it. | ||
continue; | ||
} | ||
extractedNodes.add(key.nodePath); | ||
extractedNodes.add(key.nodePath.node); | ||
state.I18NextExtract.extractedKeys.push(key); | ||
@@ -854,4 +1011,4 @@ } | ||
handleExtraction(path, state, collect => { | ||
collect(extractUseTranslationHook(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTFunction(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractUseTranslationHook(path, extractState.config, extractState.commentHints)); | ||
collect(extractTFunction(path, extractState.config, extractState.commentHints)); | ||
}); | ||
@@ -862,4 +1019,4 @@ }, | ||
handleExtraction(path, state, collect => { | ||
collect(extractTranslationRenderProp(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTransComponent(path, extractState.config, extractState.disableExtractionIntervals)); | ||
collect(extractTranslationRenderProp(path, extractState.config, extractState.commentHints)); | ||
collect(extractTransComponent(path, extractState.config, extractState.commentHints)); | ||
}); | ||
@@ -875,3 +1032,3 @@ }, | ||
extractedKeys: [], | ||
disableExtractionIntervals: [], | ||
commentHints: [], | ||
}; | ||
@@ -881,8 +1038,6 @@ }, | ||
const extractState = this.I18NextExtract; | ||
if (!extractState.extractedKeys) | ||
if (extractState.extractedKeys.length === 0) | ||
return; | ||
for (const locale of extractState.config.locales) { | ||
// eslint-disable-next-line no-console | ||
console.log(`Exporting locale: ${locale}.`); | ||
const derivedKeys = this.I18NextExtract.extractedKeys.reduce((accumulator, k) => [ | ||
const derivedKeys = extractState.extractedKeys.reduce((accumulator, k) => [ | ||
...accumulator, | ||
@@ -899,3 +1054,3 @@ ...computeDerivedKeys(k, locale, extractState.config), | ||
if (BabelTypes.isFile(path.container)) { | ||
this.I18NextExtract.disableExtractionIntervals = computeCommentDisableIntervals(path.container.comments); | ||
this.I18NextExtract.commentHints = parseCommentHints(path.container.comments); | ||
} | ||
@@ -902,0 +1057,0 @@ path.traverse(Visitor, state); |
import * as BabelTypes from '@babel/types'; | ||
import * as BabelCore from '@babel/core'; | ||
declare type CommentHintType = 'DISABLE' | 'NAMESPACE' | 'CONTEXT' | 'PLURAL'; | ||
declare type CommentHintScope = 'LINE' | 'NEXT_LINE' | 'SECTION_START' | 'SECTION_STOP'; | ||
/** | ||
* Computes line intervals where i18n extraction should be disabled. | ||
* @param comments Babel comments | ||
* @returns sections on which extraction should be disabled | ||
* Comment Hint without line location information. | ||
*/ | ||
export declare function computeCommentDisableIntervals(comments: BabelTypes.BaseComment[]): [number, number][]; | ||
interface BaseCommentHint { | ||
type: CommentHintType; | ||
scope: CommentHintScope; | ||
value: string; | ||
baseComment: BabelTypes.BaseComment; | ||
} | ||
/** | ||
* Check whether extraction is enables for a given path. | ||
* @param path: path to check | ||
* @param disableExtractionIntervals: line intervals where extraction is | ||
* disabled. | ||
* @returns true if the extraction is enabled for the given path. | ||
* Line intervals | ||
*/ | ||
export declare function extractionIsEnabledForPath(path: BabelCore.NodePath, disableExtractionIntervals: [number, number][]): boolean; | ||
interface Interval { | ||
startLine: number; | ||
stopLine: number; | ||
} | ||
/** | ||
* Comment Hint with line intervals information. | ||
*/ | ||
export interface CommentHint extends BaseCommentHint, Interval { | ||
} | ||
export declare const COMMENT_HINT_PREFIX = "i18next-extract-"; | ||
export declare const COMMENT_HINTS_KEYWORDS: { | ||
[k in CommentHintType]: { | ||
[s in CommentHintScope]: string; | ||
}; | ||
}; | ||
/** | ||
* Given Babel comments, extract the comment hints. | ||
* @param baseComments Babel comments (ordered by line) | ||
*/ | ||
export declare function parseCommentHints(baseComments: BabelTypes.BaseComment[]): CommentHint[]; | ||
/** | ||
* 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. | ||
*/ | ||
export declare function getCommentHintForPath(path: BabelCore.NodePath, commentHintType: BaseCommentHint['type'], commentHints: CommentHint[]): CommentHint | null; | ||
export {}; |
@@ -12,3 +12,4 @@ export interface Config { | ||
defaultValue: string | null; | ||
useKeyAsDefaultValue: boolean | string[]; | ||
keyAsDefaultValue: boolean | string[]; | ||
keyAsDefaultValueForDerivedKeys: boolean; | ||
exporterJsonSpace: string | number; | ||
@@ -15,0 +16,0 @@ } |
export declare const PLUGIN_NAME = "babel-plugin-i18next-extract"; | ||
export declare const COMMENT_DISABLE_LINE = "i18next-extract-disable-line"; | ||
export declare const COMMENT_DISABLE_NEXT_LINE = "i18next-extract-disable-next-line"; | ||
export declare const COMMENT_DISABLE_SECTION_START = "i18next-extract-disable"; | ||
export declare const COMMENT_DISABLE_SECTION_STOP = "i18next-extract-enable"; |
import * as BabelCore from '@babel/core'; | ||
import * as BabelTypes from '@babel/types'; | ||
import { CommentHint } from '../comments'; | ||
import { ExtractedKey } from '../keys'; | ||
/** | ||
@@ -17,5 +19,17 @@ * Error thrown in case extraction of a node failed. | ||
/** | ||
* 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. | ||
*/ | ||
export declare function parseI18NextOptionsFromCommentHints(path: BabelCore.NodePath, commentHints: CommentHint[]): Partial<ExtractedKey['parsedOptions']>; | ||
/** | ||
* Improved version of BabelCore `referencesImport` function that also tries to detect wildcard | ||
* imports. | ||
*/ | ||
export declare function referencesImport(nodePath: BabelCore.NodePath, moduleSource: string, importName: string): boolean; | ||
/** | ||
* Evaluates a node path if it can be evaluated with confidence. | ||
* | ||
* @param nodePath: node path to evaluate | ||
* @param path: node path to evaluate | ||
* @returns null if the node path couldn't be evaluated | ||
@@ -22,0 +36,0 @@ */ |
import * as BabelTypes from '@babel/types'; | ||
import * as BabelCore from '@babel/core'; | ||
import { CommentHint } from '../comments'; | ||
import { ExtractedKey } from '../keys'; | ||
@@ -11,6 +12,6 @@ import { Config } from '../config'; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is disabled | ||
* @param commentHints: parsed comment hints | ||
* @param skipCheck: set to true if you know that the call expression arguments | ||
* already is a `t` function. | ||
*/ | ||
export default function extractTFunction(path: BabelCore.NodePath<BabelTypes.CallExpression>, config: Config, disableExtractionIntervals?: [number, number][], skipCheck?: boolean): ExtractedKey[]; | ||
export default function extractTFunction(path: BabelCore.NodePath<BabelTypes.CallExpression>, config: Config, commentHints?: CommentHint[], skipCheck?: boolean): ExtractedKey[]; |
import * as BabelTypes from '@babel/types'; | ||
import * as BabelCore from '@babel/core'; | ||
import { CommentHint } from '../comments'; | ||
import { ExtractedKey } from '../keys'; | ||
@@ -11,5 +12,4 @@ import { Config } from '../config'; | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
export default function extractTransComponent(path: BabelCore.NodePath<BabelTypes.JSXElement>, config: Config, disableExtractionIntervals?: [number, number][]): ExtractedKey[]; | ||
export default function extractTransComponent(path: BabelCore.NodePath<BabelTypes.JSXElement>, config: Config, commentHints?: CommentHint[]): ExtractedKey[]; |
@@ -5,2 +5,3 @@ import * as BabelTypes from '@babel/types'; | ||
import { Config } from '../config'; | ||
import { CommentHint } from '../comments'; | ||
/** | ||
@@ -12,5 +13,4 @@ * Parse `Translation` render prop to extract all its translation keys and | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
export default function extractTranslationRenderProp(path: BabelCore.NodePath<BabelTypes.JSXElement>, config: Config, disableExtractionIntervals?: [number, number][]): ExtractedKey[]; | ||
export default function extractTranslationRenderProp(path: BabelCore.NodePath<BabelTypes.JSXElement>, config: Config, commentHints?: CommentHint[]): ExtractedKey[]; |
@@ -5,2 +5,3 @@ import * as BabelTypes from '@babel/types'; | ||
import { Config } from '../config'; | ||
import { CommentHint } from '../comments'; | ||
/** | ||
@@ -11,5 +12,4 @@ * Parse `useTranslation()` hook to extract all its translation keys and | ||
* @param config: plugin configuration | ||
* @param disableExtractionIntervals: interval of lines where extraction is | ||
* disabled | ||
* @param commentHints: parsed comment hints | ||
*/ | ||
export default function extractUseTranslationHook(path: BabelCore.NodePath<BabelTypes.CallExpression>, config: Config, disableExtractionIntervals?: [number, number][]): ExtractedKey[]; | ||
export default function extractUseTranslationHook(path: BabelCore.NodePath<BabelTypes.CallExpression>, config: Config, commentHints?: CommentHint[]): ExtractedKey[]; |
import * as BabelCore from '@babel/core'; | ||
import { Config } from './config'; | ||
interface I18NextParsedOptions { | ||
hasContext: boolean; | ||
contexts: string[] | boolean; | ||
hasCount: boolean; | ||
@@ -29,5 +29,5 @@ ns: string | null; | ||
* e.g. | ||
* ({'foo', {hasContext: false, hasCount: true}}, 'en') | ||
* ({'foo', {contexts: false, hasCount: true}}, 'en') | ||
* => ['foo', 'foo_plural'] | ||
* ({'bar', {hasContext: true, hasCount: true}}, 'en') | ||
* ({'bar', {contexts: ['male', 'female'], hasCount: true}}, 'en') | ||
* => ['foo_male', 'foo_male_plural', 'foo_female', 'foo_female_plural'] | ||
@@ -34,0 +34,0 @@ * |
import * as BabelCore from '@babel/core'; | ||
import { CommentHint } from './comments'; | ||
import { ExtractedKey } from './keys'; | ||
@@ -11,3 +12,3 @@ import { Config } from './config'; | ||
extractedKeys: ExtractedKey[]; | ||
disableExtractionIntervals: [number, number][]; | ||
commentHints: CommentHint[]; | ||
config: Config; | ||
@@ -14,0 +15,0 @@ } |
{ | ||
"name": "babel-plugin-i18next-extract", | ||
"version": "0.1.0-alpha.1", | ||
"version": "0.1.0-alpha.2", | ||
"description": "Statically extract translation keys from i18next application.", | ||
@@ -47,3 +47,3 @@ "repository": { | ||
"@babel/preset-typescript": "^7.3.3", | ||
"@types/fs-extra": "^7.0.0", | ||
"@types/fs-extra": "^8.0.0", | ||
"@types/jest": "^24.0.15", | ||
@@ -50,0 +50,0 @@ "@types/jest-expect-message": "^1.0.0", |
140
README.md
@@ -18,4 +18,2 @@ # babel-plugin-i18next-extract | ||
Just install the plugin from npm: | ||
```bash | ||
@@ -31,3 +29,3 @@ yarn add --dev babel-plugin-i18next-extract | ||
If you already use [Babel](https://babeljs.io), chances are you already have an existing babel | ||
If you already use [Babel](https://babeljs.io), chances are you already have an babel | ||
configuration (e.g. a `.babelrc` file). Just add declare the plugin and you're good to go: | ||
@@ -44,6 +42,4 @@ | ||
> To work properly, the plugin must run **before** any JSX transformation step. | ||
You can also specify additional [configuration options](#configuration) to the plugin: | ||
You can pass additional options to the plugin by declaring it as follow: | ||
```javascript | ||
@@ -58,25 +54,69 @@ { | ||
Once you are set up, you can build your app normally or run Babel through [Babel CLI]( | ||
https://babeljs.io/docs/en/babel-cli): | ||
```bash | ||
yarn run babel -f .babelrc 'src/**/*.{js,jsx,ts,tsx}' | ||
# or | ||
npm run babel -f .babelrc 'src/**/*.{js,jsx,ts,tsx}' | ||
``` | ||
Extracted translations should land in the `extractedTranslations/` directory. Magic huh? | ||
If you don't have a babel configuration yet, you can follow the [Configure Babel]( | ||
https://babeljs.io/docs/en/configuration) documentation to try setting it up. | ||
You can then just build your app normally or run Babel through [Babel CLI]( | ||
https://babeljs.io/docs/en/babel-cli): | ||
## Usage with create-react-app | ||
[create-react-app](https://github.com/facebook/create-react-app) doesn't let you modify the babel | ||
configuration. Fortunately, it's still possible to use this plugin without ejecting. First of all, | ||
install Babel CLI: | ||
```bash | ||
yarn run babel -f .babelrc 'src/**/*' | ||
yarn add --dev @babel/cli | ||
# or | ||
npm run babel -f .babelrc 'src/**/*' | ||
npm add --save-dev @babel/cli | ||
``` | ||
You should then be able to see the extracted translations in the `extractedTranslations/` | ||
directory. Magic huh? Next step is to check out all the available [configuration options | ||
](#configuration). | ||
Create a minimal `.babelrc` that uses the `react-app` babel preset (DO NOT install it, it's already | ||
shipped with CRA): | ||
## Usage with create-react-app | ||
```javascript | ||
{ | ||
"presets": ["react-app"], | ||
"plugins": ["i18next-extract"] | ||
} | ||
``` | ||
TODO: It should be enough to use babel-preset-react-app and declare the plugin in babelrc, | ||
but I have to check this out. | ||
You should then be able to extract your translations using the CLI: | ||
```bash | ||
# NODE_ENV must be specified for react-app preset to work properly | ||
NODE_ENV=development yarn run babel -f .babelrc 'src/**/*.{js,jsx,ts,tsx}' | ||
``` | ||
To simplify the extraction, you can add a script to your `package.json`: | ||
```javascript | ||
"scripts": { | ||
[…] | ||
"i18n-extract": "NODE_ENV=development babel -f .babelrc 'src/**/*.{js,jsx,ts,tsx}'", | ||
[…] | ||
} | ||
``` | ||
And then just run: | ||
```bash | ||
yarn run i18n-extract | ||
# or | ||
npm run i18n-extract | ||
``` | ||
## Features | ||
@@ -90,3 +130,3 @@ | ||
- [x] Automatic detection from `react-i18next` properties. | ||
- [ ] (todo) Manual detection from comment hints. | ||
- [x] Manual detection from [comment hints](#comment-hints). | ||
- [x] Contexts support: | ||
@@ -96,3 +136,3 @@ - [x] Naïve implementation with default contexts. | ||
- [x] Automatic detection from `react-i18next` properties. | ||
- [ ] (todo) Manual detection from comment hints. | ||
- [x] Manual detection from [comment hints](#comment-hints). | ||
- [x] [react-i18next](https://react.i18next.com/) support: | ||
@@ -108,4 +148,4 @@ - [x] `Trans` component support (with plural forms, contexts and namespaces). | ||
- [x] Depending on the `ns` attribute in the `Trans` component. | ||
- [x] Explicitely disable extraction on a specific file sections or lines using comment hints. | ||
- [x] … and more? | ||
- [x] Explicitely disable extraction on a specific file sections or lines using [comment hints](#comment-hints). | ||
- [ ] [… and more?](./CONTRIBUTING.md) | ||
@@ -126,3 +166,4 @@ ## Configuration | ||
| defaultValue | `string` or `null` | Default value for extracted keys. | `''` (empty string) | | ||
| useKeyAsDefaultValue | `boolean` or `string[]` | If true, use the extracted key as defaultValue (ignoring `defaultValue` option). You can also specify an array of locales to apply this behavior only to a specific set locales (e.g. if you keys are in plain english, you may want to set this option to `['en']`). | `false` | | ||
| keyAsDefaultValue | `boolean` or `string[]` | If true, use the extracted key as defaultValue (ignoring `defaultValue` option). You can also specify an array of locales to apply this behavior only to a specific set locales (e.g. if you keys are in plain english, you may want to set this option to `['en']`). | `false` | | ||
| keyAsDefaultValueForDerivedKeys | `boolean` | If false and `keyAsDefaultValue` is enabled, don't use derived keys (plural forms or contexts) as default value. `defaultValue` will be used instead. | `true` | | ||
| exporterJsonSpace | `number` | Number of indentation space to use in extracted JSON files. | 2 | | ||
@@ -132,2 +173,4 @@ | ||
### Disable extraction on a specific section | ||
If the plugin extracts a key you want to skip or erroneously tries to parse a function that doesn't | ||
@@ -150,8 +193,55 @@ belong to i18next, you can use a comment hint to disable the extraction: | ||
Notice you can put a `// i18next-extract-disable` comment at the top of the file in order to | ||
disable extraction on the entire file. | ||
You can put a `// i18next-extract-disable` comment at the top of the file in order to disable | ||
extraction on the entire file. | ||
Comment hints may also be used in the future to explicitly mark keys as having plural forms or | ||
contexts (and specify which ones), or to specify a namespace. Stay tuned. | ||
### Explicitly specify contexts for a key | ||
This is very useful if you want to use different contexts than the default `male` and `female` | ||
for a given key: | ||
```javascript | ||
// i18next-extract-mark-context-next-line ["dog", "cat"] | ||
i18next.t("this key will have dog and cat context", {context: dogOrCat}) | ||
// i18next-extract-mark-context-next-line | ||
i18next.t("this key will have default context, although no context is specified") | ||
// i18next-extract-mark-context-next-line disable | ||
i18next.t("this key wont have a context, although a context is specified", {context}) | ||
i18next.t("can be used on line") // i18next-extract-mark-context-line | ||
// i18next-extract-mark-context-start | ||
i18next.t("or on sections") | ||
// i18next-extract-mark-context-stop | ||
const transComponent = ( | ||
// i18next-extract-mark-context-next-line | ||
<Trans>it also works on Trans components</Trans> | ||
) | ||
``` | ||
### Explicitly use a namespace for a key | ||
```javascript | ||
// i18next-extract-mark-ns-next-line forced-ns | ||
i18next.t("this key will be in forced-ns namespace") | ||
i18next.t("this one also", {ns: 'this-ns-wont-be-used'}) // i18next-extract-mark-ns-line forced-ns | ||
// i18next-extract-mark-ns-start forced-ns | ||
i18next.t("and still this one") | ||
// i18next-extract-mark-ns-stop forced-ns | ||
``` | ||
### Explicitly enable/disable a plural form for a key | ||
```javascript | ||
// i18next-extract-mark-plural-next-line | ||
i18next.t("this key will be in forced in plural form") | ||
// i18next-extract-mark-plural-next-line disable | ||
i18next.t("this key wont have plural form", {count}) | ||
``` | ||
## Gotchas | ||
@@ -158,0 +248,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
99388
2293
265