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

View file

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

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

View file

@ -177,14 +177,10 @@ function extractSourceCodeAstTranslations(
ast: Node,
sourceCodeFilePath: string,
): SourceCodeFileTranslations {
function staticTranslateJSXWarningPart() {
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>.';
}
function sourceFileWarningPart(node: Node) {
return `File=${sourceCodeFilePath} at line=${node.loc?.start.line}`;
}
function generateCode(node: Node) {
return generate(node).code;
function sourceWarningPart(node: Node) {
return `File: ${sourceCodeFilePath} at ${
node.loc?.start.line
} line\nFull code: ${generate(node).code}`;
}
const translations: Record<string, TranslationMessage> = {};
@ -228,9 +224,9 @@ function extractSourceCodeAstTranslations(
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${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,
)}\n${generateCode(path.node)}`,
)}`,
);
}
}
@ -238,41 +234,51 @@ function extractSourceCodeAstTranslations(
return undefined;
}
// We only handle the optimistic case where we have a single non-empty content
const singleChildren = path
.get('children')
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(
(childrenPath) =>
(children) =>
!(
childrenPath.isJSXText() &&
childrenPath.node.value.replace('\n', '').trim() === ''
children.isJSXText() &&
children.node.value.replace('\n', '').trim() === ''
),
)
.pop();
if (singleChildren && singleChildren.isJSXText()) {
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 (
const isJSXText = singleChildren && singleChildren.isJSXText();
const isJSXExpressionContainer =
singleChildren &&
singleChildren.isJSXExpressionContainer() &&
(singleChildren.get('expression') as NodePath).evaluate().confident
) {
const message = (
singleChildren.get('expression') as NodePath
).evaluate().value;
(singleChildren.get('expression') as NodePath).evaluate().confident;
const id = evaluateJSXProp('id');
const description = evaluateJSXProp('description');
if (isJSXText || isJSXExpressionContainer) {
message = isJSXText
? singleChildren.node.value.trim().replace(/\s+/g, ' ')
: (singleChildren.get('expression') as NodePath).evaluate().value;
translations[id ?? message] = {
message,
@ -280,9 +286,9 @@ function extractSourceCodeAstTranslations(
};
} else {
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,
)}\n${generateCode(path.node)}`,
)}`,
);
}
},
@ -308,21 +314,21 @@ function extractSourceCodeAstTranslations(
) {
const {message, id, description} = firstArgEvaluated.value;
translations[id ?? message] = {
message,
message: message ?? id,
...(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${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,
)}\n${generateCode(path.node)}`,
)}`,
);
}
} else {
warnings.push(
`translate() function only takes 1 or 2 args\n${sourceFileWarningPart(
`translate() function only takes 1 or 2 args\n${sourceWarningPart(
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}
### `useDocusaurusContext` {#usedocusauruscontext}