From 71b6ae2fbfdbdc1e23520aecff7e35b82e0375ef Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Thu, 20 Jan 2022 00:04:33 +0800 Subject: [PATCH] feat(core): check imported API name when extracting translations (#6405) --- .../__tests__/translationsExtractor.test.ts | 313 +++++++++++++++++- .../translations/translationsExtractor.ts | 292 ++++++++-------- 2 files changed, 474 insertions(+), 131 deletions(-) diff --git a/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts index d9c3b67548..a6e1134112 100644 --- a/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts +++ b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts @@ -88,6 +88,8 @@ const unrelated = 42; const {sourceCodeFilePath} = await createTmpSourceCodeFile({ extension: 'js', content: ` +import {translate} from '@docusaurus/Translate'; + export default function MyComponent() { return (
@@ -119,6 +121,8 @@ export default function MyComponent() { const {sourceCodeFilePath} = await createTmpSourceCodeFile({ extension: 'js', content: ` +import Translate from '@docusaurus/Translate'; + export default function MyComponent() { return (
@@ -126,7 +130,7 @@ export default function MyComponent() { code message - +
); } @@ -142,7 +146,7 @@ export default function MyComponent() { sourceCodeFilePath, translations: { codeId: {message: 'code message', description: 'code description'}, - codeId1: {message: 'codeId1'}, + codeId1: {message: 'codeId1', description: 'description 2'}, }, warnings: [], }); @@ -152,6 +156,8 @@ export default function MyComponent() { const {sourceCodeFilePath} = await createTmpSourceCodeFile({ extension: 'js', content: ` +import Translate, {translate} from '@docusaurus/Translate'; + const prefix = "prefix "; export default function MyComponent() { @@ -211,6 +217,8 @@ export default function MyComponent() { const {sourceCodeFilePath} = await createTmpSourceCodeFile({ extension: 'tsx', content: ` +import {translate} from '@docusaurus/Translate'; + type ComponentProps = {toto: string} export default function MyComponent(props: ComponentProps) { @@ -236,6 +244,297 @@ export default function MyComponent(props: ComponentProps) { warnings: [], }); }); + + test('do not extract from functions that is not docusaurus provided', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import translate from 'a-lib'; + +export default function somethingElse() { + const a = translate('foo'); + return bar +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [], + }); + }); + + test('do not extract from functions that is internal', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +function translate() { + return 'foo' +} + +export default function somethingElse() { + const a = translate('foo'); + return a; +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [], + }); + }); + + test('recognize aliased imports', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import Foo, {translate as bar} from '@docusaurus/Translate'; + +export function MyComponent() { + return ( +
+ + code message + + + +
+ ); +} + +export default function () { + return ( +
+ + + +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + codeId: { + description: 'code description', + message: 'code message', + }, + codeId1: { + message: 'codeId1', + }, + }, + warnings: [], + }); + }); + + test('recognize aliased imports as string literal', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import {'translate' as bar} from '@docusaurus/Translate'; + +export default function () { + return ( +
+ + + +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + codeId1: { + message: 'codeId1', + }, + }, + warnings: [], + }); + }); + + test('warn about id if no children', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import Translate from '@docusaurus/Translate'; + +export default function () { + return ( + + ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [ + ` without children must have id prop. +Example: +File: ${sourceCodeFilePath} at line 6 +Full code: `, + ], + }); + }); + + test('warn about dynamic id', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import Translate from '@docusaurus/Translate'; + +export default function () { + return ( + foo + ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + foo: { + message: 'foo', + }, + }, + warnings: [ + ` prop=id should be a statically evaluable object. +Example: Message +Dynamically constructed values are not allowed, because they prevent translations to be extracted. +File: ${sourceCodeFilePath} at line 6 +Full code: foo`, + ], + }); + }); + + test('warn about dynamic children', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import Translate from '@docusaurus/Translate'; + +export default function () { + return ( + hhh + ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [ + `Translate content could not be extracted. It has to be a static string and use optional but static props, like text. +File: ${sourceCodeFilePath} at line 6 +Full code: hhh`, + ], + }); + }); + + test('warn about dynamic translate argument', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import {translate} from '@docusaurus/Translate'; + +translate(foo); +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [ + `translate() first arg should be a statically evaluable object. +Example: translate({message: "text",id: "optional.id",description: "optional description"} +Dynamically constructed values are not allowed, because they prevent translations to be extracted. +File: ${sourceCodeFilePath} at line 4 +Full code: translate(foo)`, + ], + }); + }); + + test('warn about too many arguments', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +import {translate} from '@docusaurus/Translate'; + +translate({message: 'a'}, {a: 1}, 2); +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [ + `translate() function only takes 1 or 2 args +File: ${sourceCodeFilePath} at line 4 +Full code: translate({ + message: 'a' +}, { + a: 1 +}, 2)`, + ], + }); + }); }); describe('extractSiteSourceCodeTranslations', () => { @@ -251,6 +550,8 @@ describe('extractSiteSourceCodeTranslations', () => { await fs.writeFile( siteComponentFile1, ` +import Translate from '@docusaurus/Translate'; + export default function MySiteComponent1() { return ( @@ -301,6 +604,8 @@ export default function MyComponent() { await fs.writeFile( plugin1File2, ` +import {translate} from '@docusaurus/Translate'; + export default function MyComponent() { return (
@@ -317,6 +622,8 @@ export default function MyComponent() { await fs.writeFile( plugin1File3, ` +import {translate} from '@docusaurus/Translate'; + export default function MyComponent() { return (
@@ -334,6 +641,8 @@ export default function MyComponent() { await fs.writeFile( plugin2File, ` +import Translate, {translate} from '@docusaurus/Translate'; + type Props = {hey: string}; export default function MyComponent(props: Props) { diff --git a/packages/docusaurus/src/server/translations/translationsExtractor.ts b/packages/docusaurus/src/server/translations/translationsExtractor.ts index ba55eb82f3..c1f4f81d6a 100644 --- a/packages/docusaurus/src/server/translations/translationsExtractor.ts +++ b/packages/docusaurus/src/server/translations/translationsExtractor.ts @@ -183,157 +183,191 @@ function extractSourceCodeAstTranslations( sourceCodeFilePath: string, ): SourceCodeFileTranslations { function sourceWarningPart(node: Node) { - return `File: ${sourceCodeFilePath} at ${ - node.loc?.start.line - } line\nFull code: ${generate(node).code}`; + return `File: ${sourceCodeFilePath} at line ${node.loc?.start.line} +Full code: ${generate(node).code}`; } const translations: Record = {}; const warnings: string[] = []; + let translateComponentName: string | undefined; + let translateFunctionName: string | undefined; - // TODO we should check the presence of the correct @docusaurus imports here! - + // First pass: find import declarations of Translate / translate. + // If not found, don't process the rest to avoid false positives traverse(ast, { - JSXElement(path) { + ImportDeclaration(path) { if ( - !path - .get('openingElement') - .get('name') - .isJSXIdentifier({name: 'Translate'}) + path.node.importKind === 'type' || + path.get('source').node.value !== '@docusaurus/Translate' ) { return; } - function evaluateJSXProp(propName: string): string | undefined { - const attributePath = path - .get('openingElement.attributes') - .find( - (attr) => - attr.isJSXAttribute() && - (attr as NodePath) - .get('name') - .isJSXIdentifier({name: propName}), - ); + const importSpecifiers = path.get('specifiers'); + const defaultImport = importSpecifiers.find( + (specifier): specifier is NodePath => + specifier.node.type === 'ImportDefaultSpecifier', + ); + const callbackImport = importSpecifiers.find( + (specifier): specifier is NodePath => + specifier.node.type === 'ImportSpecifier' && + (( + (specifier as NodePath).get('imported') + .node as t.Identifier + ).name === 'translate' || + ( + (specifier as NodePath).get('imported') + .node as t.StringLiteral + ).value === 'translate'), + ); - if (attributePath) { - const attributeValue = attributePath.get('value') as NodePath; - - const attributeValueEvaluated = - attributeValue.isJSXExpressionContainer() - ? (attributeValue.get('expression') as NodePath).evaluate() - : attributeValue.evaluate(); - - if ( - attributeValueEvaluated.confident && - typeof attributeValueEvaluated.value === 'string' - ) { - return attributeValueEvaluated.value; - } else { - warnings.push( - ` prop=${propName} should be a statically evaluable object.\nExample: Message\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceWarningPart( - path.node, - )}`, - ); - } - } - - return undefined; - } - - const id = evaluateJSXProp('id'); - const description = evaluateJSXProp('description'); - let message; - const childrenPath = path.get('children'); - - // Handle empty content - if (!childrenPath.length) { - if (!id) { - warnings.push(` - without children must have id prop.\nExample: \n${sourceWarningPart( - path.node, - )} - `); - } else { - translations[id] = { - message: message ?? id, - ...(description && {description}), - }; - } - - return; - } - - // Handle single non-empty content - const singleChildren = childrenPath - // Remove empty/useless text nodes that might be around our translation! - // Makes the translation system more reliable to JSX formatting issues - .filter( - (children) => - !( - children.isJSXText() && - children.node.value.replace('\n', '').trim() === '' - ), - ) - .pop(); - const isJSXText = singleChildren && singleChildren.isJSXText(); - const isJSXExpressionContainer = - singleChildren && - singleChildren.isJSXExpressionContainer() && - (singleChildren.get('expression') as NodePath).evaluate().confident; - - if (isJSXText || isJSXExpressionContainer) { - message = isJSXText - ? singleChildren.node.value.trim().replace(/\s+/g, ' ') - : (singleChildren.get('expression') as NodePath).evaluate().value; - - translations[id ?? message] = { - message, - ...(description && {description}), - }; - } else { - warnings.push( - `Translate content could not be extracted. It has to be a static string and use optional but static props, like text.\n${sourceWarningPart( - path.node, - )}`, - ); - } + translateComponentName = defaultImport?.get('local').node.name; + translateFunctionName = callbackImport?.get('local').node.name; }, + }); - CallExpression(path) { - if (!path.get('callee').isIdentifier({name: 'translate'})) { - return; - } - - const args = path.get('arguments'); - if (args.length === 1 || args.length === 2) { - const firstArgPath = args[0]; - - // evaluation allows translate("x" + "y"); to be considered as translate("xy"); - const firstArgEvaluated = firstArgPath.evaluate(); - + traverse(ast, { + ...(translateComponentName && { + JSXElement(path) { if ( - firstArgEvaluated.confident && - typeof firstArgEvaluated.value === 'object' + !path + .get('openingElement') + .get('name') + .isJSXIdentifier({name: translateComponentName}) ) { - const {message, id, description} = firstArgEvaluated.value; + return; + } + function evaluateJSXProp(propName: string): string | undefined { + const attributePath = path + .get('openingElement.attributes') + .find( + (attr) => + attr.isJSXAttribute() && + (attr as NodePath) + .get('name') + .isJSXIdentifier({name: propName}), + ); + + if (attributePath) { + const attributeValue = attributePath.get('value') as NodePath; + + const attributeValueEvaluated = + attributeValue.isJSXExpressionContainer() + ? (attributeValue.get('expression') as NodePath).evaluate() + : attributeValue.evaluate(); + + if ( + attributeValueEvaluated.confident && + typeof attributeValueEvaluated.value === 'string' + ) { + return attributeValueEvaluated.value; + } else { + warnings.push( + ` prop=${propName} should be a statically evaluable object. +Example: Message +Dynamically constructed values are not allowed, because they prevent translations to be extracted. +${sourceWarningPart(path.node)}`, + ); + } + } + + return undefined; + } + + const id = evaluateJSXProp('id'); + const description = evaluateJSXProp('description'); + let message; + const childrenPath = path.get('children'); + + // Handle empty content + if (!childrenPath.length) { + if (!id) { + warnings.push(` without children must have id prop. +Example: +${sourceWarningPart(path.node)}`); + } else { + translations[id] = { + message: message ?? id, + ...(description && {description}), + }; + } + + return; + } + + // Handle single non-empty content + const singleChildren = childrenPath + // Remove empty/useless text nodes that might be around our translation! + // Makes the translation system more reliable to JSX formatting issues + .filter( + (children) => + !( + children.isJSXText() && + children.node.value.replace('\n', '').trim() === '' + ), + ) + .pop(); + const isJSXText = singleChildren && singleChildren.isJSXText(); + const isJSXExpressionContainer = + singleChildren && + singleChildren.isJSXExpressionContainer() && + (singleChildren.get('expression') as NodePath).evaluate().confident; + + if (isJSXText || isJSXExpressionContainer) { + message = isJSXText + ? singleChildren.node.value.trim().replace(/\s+/g, ' ') + : (singleChildren.get('expression') as NodePath).evaluate().value; + translations[id ?? message] = { - message: message ?? id, + message, ...(description && {description}), }; } else { warnings.push( - `translate() first arg should be a statically evaluable object.\nExample: translate({message: "text",id: "optional.id",description: "optional description"}\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceWarningPart( - path.node, - )}`, + `Translate content could not be extracted. It has to be a static string and use optional but static props, like text. +${sourceWarningPart(path.node)}`, ); } - } else { - warnings.push( - `translate() function only takes 1 or 2 args\n${sourceWarningPart( - path.node, - )}`, - ); - } - }, + }, + }), + + ...(translateFunctionName && { + CallExpression(path) { + if (!path.get('callee').isIdentifier({name: translateFunctionName})) { + return; + } + + const args = path.get('arguments'); + if (args.length === 1 || args.length === 2) { + const firstArgPath = args[0]; + + // evaluation allows translate("x" + "y"); to be considered as translate("xy"); + const firstArgEvaluated = firstArgPath.evaluate(); + + if ( + firstArgEvaluated.confident && + typeof firstArgEvaluated.value === 'object' + ) { + const {message, id, description} = firstArgEvaluated.value; + translations[id ?? message] = { + message: message ?? id, + ...(description && {description}), + }; + } else { + warnings.push( + `translate() first arg should be a statically evaluable object. +Example: translate({message: "text",id: "optional.id",description: "optional description"} +Dynamically constructed values are not allowed, because they prevent translations to be extracted. +${sourceWarningPart(path.node)}`, + ); + } + } else { + warnings.push( + `translate() function only takes 1 or 2 args +${sourceWarningPart(path.node)}`, + ); + } + }, + }), }); return {sourceCodeFilePath, translations, warnings};