diff --git a/__snapshots__/test.js.snap b/__snapshots__/test.js.snap index 14fca07..fc1e44c 100644 --- a/__snapshots__/test.js.snap +++ b/__snapshots__/test.js.snap @@ -200,6 +200,20 @@ exports[`Test Fixtures plugin-config 1`] = ` exports[`Test Fixtures plugin-config 2`] = `Map {}`; +exports[`Test Fixtures react-intl 1`] = ` +"[1/4] 🔍 Finding JS and HBS files... +[2/4] 🔍 Searching for translations keys in JS and HBS files... +[3/4] ⚙️ Checking for unused translations... +[4/4] ⚙️ Checking for missing translations... + + 👏 No unused translations were found! + + 👏 No missing translations were found! +" +`; + +exports[`Test Fixtures react-intl 2`] = `Map {}`; + exports[`Test Fixtures remove-unused-translations 1`] = ` "[1/4] 🔍 Finding JS and HBS files... [2/4] 🔍 Searching for translations keys in JS and HBS files... diff --git a/fixtures/react-intl/app/react/components/my-localized-component.jsx b/fixtures/react-intl/app/react/components/my-localized-component.jsx new file mode 100644 index 0000000..37b21fc --- /dev/null +++ b/fixtures/react-intl/app/react/components/my-localized-component.jsx @@ -0,0 +1,18 @@ +import { FormattedMessage, useIntl } from 'react-intl'; + +export function MyLocalizedComponent() { + // legacy API + const { t } = useIntl(); + + // new API + const intl = useIntl(); + + return ( +
+ {t('hook-translation')} + {intl.formatMessage({ id: 'hook-translation-new' })} + {intl.formatMessage({ id: true ? 'hook-alternate' : 'hook-consequent'})} + +
+ ); +} diff --git a/fixtures/react-intl/app/react/components/my-ts-localized-component.tsx b/fixtures/react-intl/app/react/components/my-ts-localized-component.tsx new file mode 100644 index 0000000..43e1297 --- /dev/null +++ b/fixtures/react-intl/app/react/components/my-ts-localized-component.tsx @@ -0,0 +1,9 @@ +import { FormattedMessage } from 'react-intl'; + +export function MyLocalizedComponent(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/fixtures/react-intl/translations/de.json b/fixtures/react-intl/translations/de.json new file mode 100644 index 0000000..eddb982 --- /dev/null +++ b/fixtures/react-intl/translations/de.json @@ -0,0 +1,8 @@ +{ + "jsx-translation": "JSX but in german!", + "hook-translation": "Hook but in german!", + "hook-translation-new": "new hook but in german!", + "hook-alternate": "Ich bin in a ternary!", + "hook-consequent": "Ich also bin in a ternary!", + "tsx-translation": "TSX but in german!" +} diff --git a/fixtures/react-intl/translations/en.json b/fixtures/react-intl/translations/en.json new file mode 100644 index 0000000..3435385 --- /dev/null +++ b/fixtures/react-intl/translations/en.json @@ -0,0 +1,8 @@ +{ + "jsx-translation": "JSX!", + "hook-translation": "Hook!", + "hook-translation-new": "new hook!", + "hook-alternate": "I'm in a ternary!", + "hook-consequent": "I'm also in a ternary!", + "tsx-translation": "TSX!" +} diff --git a/index.js b/index.js index 5003f9e..f5a445f 100755 --- a/index.js +++ b/index.js @@ -239,6 +239,8 @@ async function analyzeFile(cwd, file, options) { if ('.gjs' === extension || (includeGtsExtension && '.gts' === extension)) { return analyzeGJSFile(content, options); + } else if (['.tsx', '.jsx'].includes(extension)) { + return analyzeJsxFile(content, options.userPlugins); } else if (['.js', ...options.userExtensions].includes(extension)) { return analyzeJsFile(content, options.userPlugins); } else if (extension === '.hbs') { @@ -298,6 +300,98 @@ async function analyzeGJSFile(gjsGtsContent, options) { return new Set([...keysFromJs, ...keysFromHbs]); } +async function analyzeJsxFile(content, userPlugins) { + let translationKeys = new Set(); + + let ast = BabelParser.parse(content, { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'dynamicImport', ...userPlugins], + }); + + // store ids passed to the component in the translationKeys set + traverse(ast, { + JSXOpeningElement({ node }) { + if (node.name.type === 'JSXIdentifier' && node.name.name === 'FormattedMessage') { + for (let attribute of node.attributes) { + if ( + attribute.type === 'JSXAttribute' && + attribute.name.name === 'id' && + attribute.value + ) { + if (attribute.value.type === 'StringLiteral') { + translationKeys.add(attribute.value.value); + } else if (attribute.value.type === 'JSXExpressionContainer') { + if (attribute.value.expression.type === 'ConditionalExpression') { + if (attribute.value.expression.alternate.type === 'StringLiteral') { + translationKeys.add(attribute.value.expression.alternate.value); + } + if (attribute.value.expression.consequent.type === 'StringLiteral') { + translationKeys.add(attribute.value.expression.consequent.value); + } + } + } + } + } + } + }, + // store ids passed to the t function in the translationKeys set + CallExpression({ node }) { + let { callee } = node; + if (node.arguments.length === 0) return; + + // handle t('foo') case + if (callee.type === 'Identifier' && callee.name === 't') { + let firstParam = node.arguments[0]; + if (firstParam.type === 'StringLiteral') { + translationKeys.add(firstParam.value); + } else if (firstParam.type === 'ConditionalExpression') { + if (firstParam.alternate.type === 'StringLiteral') { + translationKeys.add(firstParam.alternate.value); + } + if (firstParam.consequent.type === 'StringLiteral') { + translationKeys.add(firstParam.consequent.value); + } + } + } + // handle intl.formatMessage({ id: 'foo' }) case + else if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'intl' && + callee.property.type === 'Identifier' && + callee.property.name === 'formatMessage' + ) { + let firstParam = node.arguments[0]; + if (firstParam.type === 'ObjectExpression') { + for (let property of firstParam.properties) { + if ( + property.type === 'ObjectProperty' && + property.key.type === 'Identifier' && + property.key.name === 'id' + ) { + // if it's a string literal, add it to the translationKeys set + if (property.value.type === 'StringLiteral') { + translationKeys.add(property.value.value); + } + // else, if it's a ternary operator, add the consequent and alternate to the translationKeys set + else if (property.value.type === 'ConditionalExpression') { + if (property.value.alternate.type === 'StringLiteral') { + translationKeys.add(property.value.alternate.value); + } + if (property.value.consequent.type === 'StringLiteral') { + translationKeys.add(property.value.consequent.value); + } + } + } + } + } + } + }, + }); + + return translationKeys; +} + async function analyzeJsFile(content, userPlugins) { let translationKeys = new Set(); diff --git a/test.js b/test.js index 6a3d6aa..ae46abe 100644 --- a/test.js +++ b/test.js @@ -43,6 +43,9 @@ describe('Test Fixtures', () => { 'custom-t-helpers': { helpers: ['t-error'], }, + 'react-intl': { + extensions: ['.jsx', '.tsx'], + } }; beforeEach(() => {