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({
extension: 'js',
content: `
import {translate} from '@docusaurus/Translate';
export default function MyComponent() {
return (
<div>
@ -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 (
<div>
@ -126,7 +130,7 @@ export default function MyComponent() {
code message
</Translate>
<Translate id="codeId1" />
<Translate id="codeId1" description="description 2" />
</div>
);
}
@ -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<T> = {toto: string}
export default function MyComponent<T>(props: ComponentProps<T>) {
@ -236,6 +244,297 @@ export default function MyComponent<T>(props: ComponentProps<T>) {
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', () => {
@ -251,6 +550,8 @@ describe('extractSiteSourceCodeTranslations', () => {
await fs.writeFile(
siteComponentFile1,
`
import Translate from '@docusaurus/Translate';
export default function MySiteComponent1() {
return (
<Translate
@ -283,6 +584,8 @@ export default function MySiteComponent1() {
await fs.writeFile(
plugin1File1,
`
import {translate} from '@docusaurus/Translate';
export default function MyComponent() {
return (
<div>
@ -301,6 +604,8 @@ export default function MyComponent() {
await fs.writeFile(
plugin1File2,
`
import {translate} from '@docusaurus/Translate';
export default function MyComponent() {
return (
<div>
@ -317,6 +622,8 @@ export default function MyComponent() {
await fs.writeFile(
plugin1File3,
`
import {translate} from '@docusaurus/Translate';
export default function MyComponent() {
return (
<div>
@ -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) {

View file

@ -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<string, TranslationMessage> = {};
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<t.JSXAttribute>)
.get('name')
.isJSXIdentifier({name: propName}),
);
const importSpecifiers = path.get('specifiers');
const defaultImport = importSpecifiers.find(
(specifier): specifier is NodePath<t.ImportDefaultSpecifier> =>
specifier.node.type === 'ImportDefaultSpecifier',
);
const callbackImport = importSpecifiers.find(
(specifier): specifier is NodePath<t.ImportSpecifier> =>
specifier.node.type === 'ImportSpecifier' &&
((
(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) {
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.\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,
)}`,
);
}
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<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] = {
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 <Translate id="my-id" description="my-description">text</Translate>.
${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};