feat(core): check imported API name when extracting translations (#6405)

This commit is contained in:
Joshua Chen 2022-01-20 00:04:33 +08:00 committed by GitHub
parent 2cace21083
commit 71b6ae2fbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 474 additions and 131 deletions

View file

@ -88,6 +88,8 @@ const unrelated = 42;
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js', extension: 'js',
content: ` content: `
import {translate} from '@docusaurus/Translate';
export default function MyComponent() { export default function MyComponent() {
return ( return (
<div> <div>
@ -119,6 +121,8 @@ export default function MyComponent() {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js', extension: 'js',
content: ` content: `
import Translate from '@docusaurus/Translate';
export default function MyComponent() { export default function MyComponent() {
return ( return (
<div> <div>
@ -126,7 +130,7 @@ export default function MyComponent() {
code message code message
</Translate> </Translate>
<Translate id="codeId1" /> <Translate id="codeId1" description="description 2" />
</div> </div>
); );
} }
@ -142,7 +146,7 @@ export default function MyComponent() {
sourceCodeFilePath, sourceCodeFilePath,
translations: { translations: {
codeId: {message: 'code message', description: 'code description'}, codeId: {message: 'code message', description: 'code description'},
codeId1: {message: 'codeId1'}, codeId1: {message: 'codeId1', description: 'description 2'},
}, },
warnings: [], warnings: [],
}); });
@ -152,6 +156,8 @@ export default function MyComponent() {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js', extension: 'js',
content: ` content: `
import Translate, {translate} from '@docusaurus/Translate';
const prefix = "prefix "; const prefix = "prefix ";
export default function MyComponent() { export default function MyComponent() {
@ -211,6 +217,8 @@ export default function MyComponent() {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'tsx', extension: 'tsx',
content: ` content: `
import {translate} from '@docusaurus/Translate';
type ComponentProps<T> = {toto: string} type ComponentProps<T> = {toto: string}
export default function MyComponent<T>(props: ComponentProps<T>) { export default function MyComponent<T>(props: ComponentProps<T>) {
@ -236,6 +244,297 @@ export default function MyComponent<T>(props: ComponentProps<T>) {
warnings: [], 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 <Translate>bar</Translate>
}
`,
});
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 (
<div>
<Foo id="codeId" description={"code description"}>
code message
</Foo>
<Translate id="codeId1" />
</div>
);
}
export default function () {
return (
<div>
<input text={translate({id: 'codeId',message: 'code message',description: 'code description'})}/>
<input text={bar({id: 'codeId1'})}/>
</div>
);
}
`,
});
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 (
<div>
<input text={translate({id: 'codeId',message: 'code message',description: 'code description'})}/>
<input text={bar({id: 'codeId1'})}/>
</div>
);
}
`,
});
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 (
<Translate description="foo" />
);
}
`,
});
const sourceCodeFileTranslations = await extractSourceCodeFileTranslations(
sourceCodeFilePath,
TestBabelOptions,
);
expect(sourceCodeFileTranslations).toEqual({
sourceCodeFilePath,
translations: {},
warnings: [
`<Translate> without children must have id prop.
Example: <Translate id="my-id" />
File: ${sourceCodeFilePath} at line 6
Full code: <Translate description="foo" />`,
],
});
});
test('warn about dynamic id', async () => {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js',
content: `
import Translate from '@docusaurus/Translate';
export default function () {
return (
<Translate id={index}>foo</Translate>
);
}
`,
});
const sourceCodeFileTranslations = await extractSourceCodeFileTranslations(
sourceCodeFilePath,
TestBabelOptions,
);
expect(sourceCodeFileTranslations).toEqual({
sourceCodeFilePath,
translations: {
foo: {
message: 'foo',
},
},
warnings: [
`<Translate> prop=id should be a statically evaluable object.
Example: <Translate id="optional id" description="optional description">Message</Translate>
Dynamically constructed values are not allowed, because they prevent translations to be extracted.
File: ${sourceCodeFilePath} at line 6
Full code: <Translate id={index}>foo</Translate>`,
],
});
});
test('warn about dynamic children', async () => {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js',
content: `
import Translate from '@docusaurus/Translate';
export default function () {
return (
<Translate id='foo'><a>hhh</a></Translate>
);
}
`,
});
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 <Translate id="my-id" description="my-description">text</Translate>.
File: ${sourceCodeFilePath} at line 6
Full code: <Translate id='foo'><a>hhh</a></Translate>`,
],
});
});
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', () => { describe('extractSiteSourceCodeTranslations', () => {
@ -251,6 +550,8 @@ describe('extractSiteSourceCodeTranslations', () => {
await fs.writeFile( await fs.writeFile(
siteComponentFile1, siteComponentFile1,
` `
import Translate from '@docusaurus/Translate';
export default function MySiteComponent1() { export default function MySiteComponent1() {
return ( return (
<Translate <Translate
@ -283,6 +584,8 @@ export default function MySiteComponent1() {
await fs.writeFile( await fs.writeFile(
plugin1File1, plugin1File1,
` `
import {translate} from '@docusaurus/Translate';
export default function MyComponent() { export default function MyComponent() {
return ( return (
<div> <div>
@ -301,6 +604,8 @@ export default function MyComponent() {
await fs.writeFile( await fs.writeFile(
plugin1File2, plugin1File2,
` `
import {translate} from '@docusaurus/Translate';
export default function MyComponent() { export default function MyComponent() {
return ( return (
<div> <div>
@ -317,6 +622,8 @@ export default function MyComponent() {
await fs.writeFile( await fs.writeFile(
plugin1File3, plugin1File3,
` `
import {translate} from '@docusaurus/Translate';
export default function MyComponent() { export default function MyComponent() {
return ( return (
<div> <div>
@ -334,6 +641,8 @@ export default function MyComponent() {
await fs.writeFile( await fs.writeFile(
plugin2File, plugin2File,
` `
import Translate, {translate} from '@docusaurus/Translate';
type Props = {hey: string}; type Props = {hey: string};
export default function MyComponent(props: Props) { export default function MyComponent(props: Props) {

View file

@ -183,157 +183,191 @@ function extractSourceCodeAstTranslations(
sourceCodeFilePath: string, sourceCodeFilePath: string,
): SourceCodeFileTranslations { ): SourceCodeFileTranslations {
function sourceWarningPart(node: Node) { function sourceWarningPart(node: Node) {
return `File: ${sourceCodeFilePath} at ${ return `File: ${sourceCodeFilePath} at line ${node.loc?.start.line}
node.loc?.start.line Full code: ${generate(node).code}`;
} line\nFull code: ${generate(node).code}`;
} }
const translations: Record<string, TranslationMessage> = {}; const translations: Record<string, TranslationMessage> = {};
const warnings: string[] = []; 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, { traverse(ast, {
JSXElement(path) { ImportDeclaration(path) {
if ( if (
!path path.node.importKind === 'type' ||
.get('openingElement') path.get('source').node.value !== '@docusaurus/Translate'
.get('name')
.isJSXIdentifier({name: 'Translate'})
) { ) {
return; return;
} }
function evaluateJSXProp(propName: string): string | undefined { const importSpecifiers = path.get('specifiers');
const attributePath = path const defaultImport = importSpecifiers.find(
.get('openingElement.attributes') (specifier): specifier is NodePath<t.ImportDefaultSpecifier> =>
.find( specifier.node.type === 'ImportDefaultSpecifier',
(attr) => );
attr.isJSXAttribute() && const callbackImport = importSpecifiers.find(
(attr as NodePath<t.JSXAttribute>) (specifier): specifier is NodePath<t.ImportSpecifier> =>
.get('name') specifier.node.type === 'ImportSpecifier' &&
.isJSXIdentifier({name: propName}), ((
); (specifier as NodePath<t.ImportSpecifier>).get('imported')
.node as t.Identifier
).name === 'translate' ||
(
(specifier as NodePath<t.ImportSpecifier>).get('imported')
.node as t.StringLiteral
).value === 'translate'),
);
if (attributePath) { translateComponentName = defaultImport?.get('local').node.name;
const attributeValue = attributePath.get('value') as NodePath; translateFunctionName = callbackImport?.get('local').node.name;
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(
`<Translate> prop=${propName} should be a statically evaluable object.\nExample: <Translate id="optional.id" description="optional description">Message</Translate>\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(`
<Translate> without children must have id prop.\nExample: <Translate id="my-id" />\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 <Translate id="my-id" description="my-description">text</Translate>.\n${sourceWarningPart(
path.node,
)}`,
);
}
}, },
});
CallExpression(path) { traverse(ast, {
if (!path.get('callee').isIdentifier({name: 'translate'})) { ...(translateComponentName && {
return; JSXElement(path) {
}
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 ( if (
firstArgEvaluated.confident && !path
typeof firstArgEvaluated.value === 'object' .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<t.JSXAttribute>)
.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(
`<Translate> prop=${propName} should be a statically evaluable object.
Example: <Translate id="optional id" description="optional description">Message</Translate>
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(`<Translate> without children must have id prop.
Example: <Translate id="my-id" />
${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] = { translations[id ?? message] = {
message: message ?? id, message,
...(description && {description}), ...(description && {description}),
}; };
} else { } else {
warnings.push( 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( `Translate content could not be extracted. It has to be a static string and use optional but static props, like <Translate id="my-id" description="my-description">text</Translate>.
path.node, ${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}; return {sourceCodeFilePath, translations, warnings};