feat: make Translate children optional (#5683)

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Alexey Pyltsyn 2021-10-14 19:39:41 +03:00 committed by GitHub
parent c8739ec28e
commit 92104c7c3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 123 additions and 69 deletions

View file

@ -141,16 +141,16 @@ declare module '@docusaurus/Interpolate' {
} }
declare module '@docusaurus/Translate' { declare module '@docusaurus/Translate' {
import type { import type {ReactNode} from 'react';
InterpolateProps, import type {InterpolateValues} from '@docusaurus/Interpolate';
InterpolateValues,
} from '@docusaurus/Interpolate';
export type TranslateParam<Str extends string> = Partial< // TS type to ensure that at least one of id or message is always provided
InterpolateProps<Str> // (Generic permits to handled message provided as React children)
> & { type IdOrMessage<MessageKey extends 'children' | 'message'> =
message: Str; | ({[key in MessageKey]: string} & {id?: string})
id?: string; | ({[key in MessageKey]?: string} & {id: string});
export type TranslateParam<Str extends string> = IdOrMessage<'message'> & {
description?: string; description?: string;
values?: InterpolateValues<Str, string | number>; values?: InterpolateValues<Str, string | number>;
}; };
@ -160,9 +160,9 @@ declare module '@docusaurus/Translate' {
values?: InterpolateValues<Str, string | number>, values?: InterpolateValues<Str, string | number>,
): string; ): string;
export type TranslateProps<Str extends string> = InterpolateProps<Str> & { export type TranslateProps<Str extends string> = IdOrMessage<'children'> & {
id?: string;
description?: string; description?: string;
values?: InterpolateValues<Str, ReactNode>;
}; };
export default function Translate<Str extends string>( export default function Translate<Str extends string>(

View file

@ -5,11 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React from 'react'; import {ReactNode} from 'react';
import Interpolate, { import {interpolate, InterpolateValues} from '@docusaurus/Interpolate';
interpolate,
InterpolateValues,
} from '@docusaurus/Interpolate';
import type {TranslateParam, TranslateProps} from '@docusaurus/Translate'; import type {TranslateParam, TranslateProps} from '@docusaurus/Translate';
// Can't read it from context, due to exposing imperative API // Can't read it from context, due to exposing imperative API
@ -19,10 +16,16 @@ function getLocalizedMessage({
id, id,
message, message,
}: { }: {
message: string; message?: string;
id?: string; id?: string;
}): string { }): string {
return codeTranslations[id ?? message] ?? message; if (typeof id === 'undefined' && typeof message === 'undefined') {
throw new Error(
'Docusaurus translation declarations must have at least a translation id or a default translation message',
);
}
return codeTranslations[(id ?? message)!] ?? message ?? id;
} }
// Imperative translation API is useful for some edge-cases: // Imperative translation API is useful for some edge-cases:
@ -32,7 +35,7 @@ export function translate<Str extends string>(
{message, id}: TranslateParam<Str>, {message, id}: TranslateParam<Str>,
values?: InterpolateValues<Str, string | number>, values?: InterpolateValues<Str, string | number>,
): string { ): string {
const localizedMessage = getLocalizedMessage({message, id}) ?? message; const localizedMessage = getLocalizedMessage({message, id});
return interpolate(localizedMessage, values); return interpolate(localizedMessage, values);
} }
@ -42,16 +45,14 @@ export default function Translate<Str extends string>({
children, children,
id, id,
values, values,
}: TranslateProps<Str>): JSX.Element { }: TranslateProps<Str>): ReactNode {
if (typeof children !== 'string') { if (children && typeof children !== 'string') {
console.warn('Illegal <Translate> children', children); console.warn('Illegal <Translate> children', children);
throw new Error( throw new Error(
'The Docusaurus <Translate> component only accept simple string values', 'The Docusaurus <Translate> component only accept simple string values',
); );
} }
const localizedMessage: string = const localizedMessage: string = getLocalizedMessage({message: children, id});
getLocalizedMessage({message: children, id}) ?? children; return interpolate(localizedMessage, values);
return <Interpolate values={values}>{localizedMessage}</Interpolate>;
} }

View file

@ -0,0 +1,31 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {translate} from '../Translate';
describe('translate', () => {
test('accept id and use it as fallback', () => {
expect(translate({id: 'some-id'})).toEqual('some-id');
});
test('accept message and use it as fallback', () => {
expect(translate({message: 'some-message'})).toEqual('some-message');
});
test('accept id+message and use message as fallback', () => {
expect(translate({id: 'some-id', message: 'some-message'})).toEqual(
'some-message',
);
});
test('reject when no id or message', () => {
// @ts-expect-error: TS should protect when both id/message are missing
expect(() => translate({})).toThrowErrorMatchingInlineSnapshot(
`"Docusaurus translation declarations must have at least a translation id or a default translation message"`,
);
});
});

View file

@ -84,7 +84,7 @@ const unrelated = 42;
}); });
}); });
test('extract from a translate() function call', async () => { test('extract from a translate() functions calls', async () => {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js', extension: 'js',
content: ` content: `
@ -92,6 +92,8 @@ export default function MyComponent() {
return ( return (
<div> <div>
<input text={translate({id: 'codeId',message: 'code message',description: 'code description'})}/> <input text={translate({id: 'codeId',message: 'code message',description: 'code description'})}/>
<input text={translate({id: 'codeId1'})}/>
</div> </div>
); );
} }
@ -107,12 +109,13 @@ 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'},
}, },
warnings: [], warnings: [],
}); });
}); });
test('extract from a <Translate> component', async () => { test('extract from a <Translate> components', async () => {
const {sourceCodeFilePath} = await createTmpSourceCodeFile({ const {sourceCodeFilePath} = await createTmpSourceCodeFile({
extension: 'js', extension: 'js',
content: ` content: `
@ -122,6 +125,8 @@ export default function MyComponent() {
<Translate id="codeId" description={"code description"}> <Translate id="codeId" description={"code description"}>
code message code message
</Translate> </Translate>
<Translate id="codeId1" />
</div> </div>
); );
} }
@ -137,6 +142,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'},
}, },
warnings: [], warnings: [],
}); });

View file

@ -177,14 +177,10 @@ function extractSourceCodeAstTranslations(
ast: Node, ast: Node,
sourceCodeFilePath: string, sourceCodeFilePath: string,
): SourceCodeFileTranslations { ): SourceCodeFileTranslations {
function staticTranslateJSXWarningPart() { function sourceWarningPart(node: Node) {
return 'Translate content could not be extracted.\nIt has to be a static string and use optional but static props, like <Translate id="my-id" description="my-description">text</Translate>.'; return `File: ${sourceCodeFilePath} at ${
} node.loc?.start.line
function sourceFileWarningPart(node: Node) { } line\nFull code: ${generate(node).code}`;
return `File=${sourceCodeFilePath} at line=${node.loc?.start.line}`;
}
function generateCode(node: Node) {
return generate(node).code;
} }
const translations: Record<string, TranslationMessage> = {}; const translations: Record<string, TranslationMessage> = {};
@ -228,9 +224,9 @@ function extractSourceCodeAstTranslations(
return attributeValueEvaluated.value; return attributeValueEvaluated.value;
} else { } else {
warnings.push( 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${sourceFileWarningPart( `<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, path.node,
)}\n${generateCode(path.node)}`, )}`,
); );
} }
} }
@ -238,41 +234,51 @@ function extractSourceCodeAstTranslations(
return undefined; return undefined;
} }
// We only handle the optimistic case where we have a single non-empty content const id = evaluateJSXProp('id');
const singleChildren = path const description = evaluateJSXProp('description');
.get('children') 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! // Remove empty/useless text nodes that might be around our translation!
// Makes the translation system more reliable to JSX formatting issues // Makes the translation system more reliable to JSX formatting issues
.filter( .filter(
(childrenPath) => (children) =>
!( !(
childrenPath.isJSXText() && children.isJSXText() &&
childrenPath.node.value.replace('\n', '').trim() === '' children.node.value.replace('\n', '').trim() === ''
), ),
) )
.pop(); .pop();
const isJSXText = singleChildren && singleChildren.isJSXText();
if (singleChildren && singleChildren.isJSXText()) { const isJSXExpressionContainer =
const message = singleChildren.node.value.trim().replace(/\s+/g, ' ');
const id = evaluateJSXProp('id');
const description = evaluateJSXProp('description');
translations[id ?? message] = {
message,
...(description && {description}),
};
} else if (
singleChildren && singleChildren &&
singleChildren.isJSXExpressionContainer() && singleChildren.isJSXExpressionContainer() &&
(singleChildren.get('expression') as NodePath).evaluate().confident (singleChildren.get('expression') as NodePath).evaluate().confident;
) {
const message = (
singleChildren.get('expression') as NodePath
).evaluate().value;
const id = evaluateJSXProp('id'); if (isJSXText || isJSXExpressionContainer) {
const description = evaluateJSXProp('description'); message = isJSXText
? singleChildren.node.value.trim().replace(/\s+/g, ' ')
: (singleChildren.get('expression') as NodePath).evaluate().value;
translations[id ?? message] = { translations[id ?? message] = {
message, message,
@ -280,9 +286,9 @@ function extractSourceCodeAstTranslations(
}; };
} else { } else {
warnings.push( warnings.push(
`${staticTranslateJSXWarningPart()}\n${sourceFileWarningPart( `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, path.node,
)}\n${generateCode(path.node)}`, )}`,
); );
} }
}, },
@ -308,21 +314,21 @@ function extractSourceCodeAstTranslations(
) { ) {
const {message, id, description} = firstArgEvaluated.value; const {message, id, description} = firstArgEvaluated.value;
translations[id ?? message] = { translations[id ?? message] = {
message, message: message ?? id,
...(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${sourceFileWarningPart( `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, path.node,
)}\n${generateCode(path.node)}`, )}`,
); );
} }
} else { } else {
warnings.push( warnings.push(
`translate() function only takes 1 or 2 args\n${sourceFileWarningPart( `translate() function only takes 1 or 2 args\n${sourceWarningPart(
path.node, path.node,
)}\n${generateCode(path.node)}`, )}`,
); );
} }
}, },

View file

@ -253,6 +253,16 @@ export default function Home() {
} }
``` ```
:::note
You can even omit a children prop and specify a translation string in your `code.json` file manually after running the `docusaurus write-translations` CLI command.
```jsx
<Translate id="homepage.title" />
```
:::
## Hooks {#hooks} ## Hooks {#hooks}
### `useDocusaurusContext` {#usedocusauruscontext} ### `useDocusaurusContext` {#usedocusauruscontext}