mirror of
https://github.com/facebook/docusaurus.git
synced 2025-08-03 00:39:45 +02:00
feat: make Translate children optional (#5683)
Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
parent
c8739ec28e
commit
92104c7c3b
6 changed files with 123 additions and 69 deletions
|
@ -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>(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
@ -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)}`,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue